@ozyman42/ozy-cli 0.4.8 → 0.4.9-darwin-x64.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/multi-call-binary +0 -0
- package/package.json +11 -52
- package/bin/ozy +0 -191
- package/bin/ozy-signing-agent +0 -191
- package/bin/ozy-ssh-keygen +0 -191
- package/bin/ozy-virtual-security-key +0 -191
- package/dist/commands/git/hosts.js +0 -18
- package/dist/commands/git/hosts.js.map +0 -1
- package/dist/commands/git/index.js +0 -8
- package/dist/commands/git/index.js.map +0 -1
- package/dist/commands/git/setup.js +0 -145
- package/dist/commands/git/setup.js.map +0 -1
- package/dist/commands/index.js +0 -13
- package/dist/commands/index.js.map +0 -1
- package/dist/commands/npm/index.js +0 -7
- package/dist/commands/npm/index.js.map +0 -1
- package/dist/commands/npm/setup/git-remote.js +0 -66
- package/dist/commands/npm/setup/git-remote.js.map +0 -1
- package/dist/commands/npm/setup/index.js +0 -168
- package/dist/commands/npm/setup/index.js.map +0 -1
- package/dist/commands/npm/setup/npm-auth.js +0 -62
- package/dist/commands/npm/setup/npm-auth.js.map +0 -1
- package/dist/commands/npm/setup/package-json-validate.js +0 -47
- package/dist/commands/npm/setup/package-json-validate.js.map +0 -1
- package/dist/commands/npm/setup/package-registry.js +0 -20
- package/dist/commands/npm/setup/package-registry.js.map +0 -1
- package/dist/commands/npm/setup/setup.test.js +0 -95
- package/dist/commands/npm/setup/setup.test.js.map +0 -1
- package/dist/commands/npm/setup/trusted-publishing.js +0 -84
- package/dist/commands/npm/setup/trusted-publishing.js.map +0 -1
- package/dist/commands/npm/setup/workflow.js +0 -19
- package/dist/commands/npm/setup/workflow.js.map +0 -1
- package/dist/commands/ssh/get-sk-credential.js +0 -104
- package/dist/commands/ssh/get-sk-credential.js.map +0 -1
- package/dist/commands/ssh/index.js +0 -7
- package/dist/commands/ssh/index.js.map +0 -1
- package/dist/commands/upgrade.js +0 -199
- package/dist/commands/upgrade.js.map +0 -1
- package/dist/common/command.js +0 -15
- package/dist/common/command.js.map +0 -1
- package/dist/common/constants.js +0 -25
- package/dist/common/constants.js.map +0 -1
- package/dist/common/effective-modules-extensions.js +0 -13
- package/dist/common/effective-modules-extensions.js.map +0 -1
- package/dist/common/log.js +0 -4
- package/dist/common/log.js.map +0 -1
- package/dist/common/render-caller-tree.js +0 -20
- package/dist/common/render-caller-tree.js.map +0 -1
- package/dist/entrypoints/ozy-signing-agent.js +0 -48
- package/dist/entrypoints/ozy-signing-agent.js.map +0 -1
- package/dist/entrypoints/ozy-ssh-keygen.js +0 -9
- package/dist/entrypoints/ozy-ssh-keygen.js.map +0 -1
- package/dist/entrypoints/ozy-virtual-security-key.js +0 -18
- package/dist/entrypoints/ozy-virtual-security-key.js.map +0 -1
- package/dist/entrypoints/ozy.js +0 -3
- package/dist/entrypoints/ozy.js.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/internal/thing.js +0 -4
- package/dist/internal/thing.js.map +0 -1
- package/dist/modules/cli/agent-client/impl.js +0 -109
- package/dist/modules/cli/agent-client/impl.js.map +0 -1
- package/dist/modules/cli/agent-client/interface.js +0 -11
- package/dist/modules/cli/agent-client/interface.js.map +0 -1
- package/dist/modules/cli/git/impl.js +0 -124
- package/dist/modules/cli/git/impl.js.map +0 -1
- package/dist/modules/cli/git/interface.js +0 -3
- package/dist/modules/cli/git/interface.js.map +0 -1
- package/dist/modules/cli/git/submodule/fix.js +0 -257
- package/dist/modules/cli/git/submodule/fix.js.map +0 -1
- package/dist/modules/cli/git/submodule/git-state.js +0 -154
- package/dist/modules/cli/git/submodule/git-state.js.map +0 -1
- package/dist/modules/cli/git/submodule/gitmodules.js +0 -113
- package/dist/modules/cli/git/submodule/gitmodules.js.map +0 -1
- package/dist/modules/cli/git/submodule/printer.js +0 -232
- package/dist/modules/cli/git/submodule/printer.js.map +0 -1
- package/dist/modules/cli/git/submodule/resolve-git-dir.js +0 -16
- package/dist/modules/cli/git/submodule/resolve-git-dir.js.map +0 -1
- package/dist/modules/cli/github/impl.js +0 -137
- package/dist/modules/cli/github/impl.js.map +0 -1
- package/dist/modules/cli/github/interface.js +0 -2
- package/dist/modules/cli/github/interface.js.map +0 -1
- package/dist/modules/cli/index.js +0 -9
- package/dist/modules/cli/index.js.map +0 -1
- package/dist/modules/common/crypto/impl.js +0 -133
- package/dist/modules/common/crypto/impl.js.map +0 -1
- package/dist/modules/common/crypto/interface.js +0 -2
- package/dist/modules/common/crypto/interface.js.map +0 -1
- package/dist/modules/common/index.js +0 -10
- package/dist/modules/common/index.js.map +0 -1
- package/dist/modules/common/kep-map-store/impl.js +0 -42
- package/dist/modules/common/kep-map-store/impl.js.map +0 -1
- package/dist/modules/common/kep-map-store/interface.js +0 -2
- package/dist/modules/common/kep-map-store/interface.js.map +0 -1
- package/dist/modules/common/os-platform/caller-info.js +0 -132
- package/dist/modules/common/os-platform/caller-info.js.map +0 -1
- package/dist/modules/common/os-platform/impl.js +0 -95
- package/dist/modules/common/os-platform/impl.js.map +0 -1
- package/dist/modules/common/os-platform/interface.js +0 -2
- package/dist/modules/common/os-platform/interface.js.map +0 -1
- package/dist/modules/common/os-platform/virtual-hid/index.js +0 -12
- package/dist/modules/common/os-platform/virtual-hid/index.js.map +0 -1
- package/dist/modules/common/os-platform/virtual-hid/linux.js +0 -5
- package/dist/modules/common/os-platform/virtual-hid/linux.js.map +0 -1
- package/dist/modules/common/os-platform/virtual-hid/mac.js +0 -119
- package/dist/modules/common/os-platform/virtual-hid/mac.js.map +0 -1
- package/dist/modules/common/os-platform/virtual-hid/windows.js +0 -5
- package/dist/modules/common/os-platform/virtual-hid/windows.js.map +0 -1
- package/dist/modules/common/ssh-config/impl.js +0 -116
- package/dist/modules/common/ssh-config/impl.js.map +0 -1
- package/dist/modules/common/ssh-config/interface.js +0 -10
- package/dist/modules/common/ssh-config/interface.js.map +0 -1
- package/dist/modules/ssh-agent/index.js +0 -8
- package/dist/modules/ssh-agent/index.js.map +0 -1
- package/dist/modules/ssh-agent/session/impl.js +0 -265
- package/dist/modules/ssh-agent/session/impl.js.map +0 -1
- package/dist/modules/ssh-agent/session/interface-rpc.js +0 -20
- package/dist/modules/ssh-agent/session/interface-rpc.js.map +0 -1
- package/dist/modules/ssh-agent/session/interface.js +0 -32
- package/dist/modules/ssh-agent/session/interface.js.map +0 -1
- package/dist/modules/ssh-agent/session/passkey-prf-page.js +0 -204
- package/dist/modules/ssh-agent/session/passkey-prf-page.js.map +0 -1
- package/dist/modules/ssh-agent/session/prf-flow.js +0 -103
- package/dist/modules/ssh-agent/session/prf-flow.js.map +0 -1
- package/dist/modules/ssh-agent/ssh-agent/impl.js +0 -117
- package/dist/modules/ssh-agent/ssh-agent/impl.js.map +0 -1
- package/dist/modules/ssh-agent/ssh-agent/interface.js +0 -2
- package/dist/modules/ssh-agent/ssh-agent/interface.js.map +0 -1
- package/dist/scripts/build.js +0 -410
- package/dist/scripts/build.js.map +0 -1
- package/dist/scripts/check-npm-version.js +0 -8
- package/dist/scripts/check-npm-version.js.map +0 -1
- package/dist/scripts/kill-existing-agent.js +0 -21
- package/dist/scripts/kill-existing-agent.js.map +0 -1
- package/dist/scripts/publish.js +0 -46
- package/dist/scripts/publish.js.map +0 -1
- package/src/commands/git/hosts.ts +0 -20
- package/src/commands/git/index.ts +0 -9
- package/src/commands/git/setup.ts +0 -182
- package/src/commands/index.ts +0 -15
- package/src/commands/npm/index.ts +0 -8
- package/src/commands/npm/setup/git-remote.ts +0 -78
- package/src/commands/npm/setup/index.ts +0 -189
- package/src/commands/npm/setup/npm-auth.ts +0 -58
- package/src/commands/npm/setup/package-json-validate.ts +0 -68
- package/src/commands/npm/setup/package-registry.ts +0 -24
- package/src/commands/npm/setup/publish-workflow.yml +0 -35
- package/src/commands/npm/setup/setup.test.ts +0 -117
- package/src/commands/npm/setup/trusted-publishing.ts +0 -106
- package/src/commands/npm/setup/workflow.ts +0 -20
- package/src/commands/ssh/get-sk-credential.ts +0 -110
- package/src/commands/ssh/index.ts +0 -8
- package/src/commands/upgrade.ts +0 -220
- package/src/common/command.ts +0 -16
- package/src/common/constants.ts +0 -26
- package/src/common/effective-modules-extensions.ts +0 -24
- package/src/common/log.ts +0 -3
- package/src/common/render-caller-tree.ts +0 -22
- package/src/entrypoints/ozy-signing-agent.ts +0 -48
- package/src/entrypoints/ozy-ssh-keygen.ts +0 -10
- package/src/entrypoints/ozy-virtual-security-key.ts +0 -21
- package/src/entrypoints/ozy.ts +0 -2
- package/src/index.ts +0 -1
- package/src/internal/thing.ts +0 -3
- package/src/modules/cli/agent-client/impl.ts +0 -152
- package/src/modules/cli/agent-client/interface.ts +0 -17
- package/src/modules/cli/git/impl.ts +0 -133
- package/src/modules/cli/git/interface.ts +0 -23
- package/src/modules/cli/git/submodule/fix.ts +0 -298
- package/src/modules/cli/git/submodule/git-state.ts +0 -217
- package/src/modules/cli/git/submodule/gitmodules.ts +0 -126
- package/src/modules/cli/git/submodule/printer.ts +0 -256
- package/src/modules/cli/git/submodule/resolve-git-dir.ts +0 -14
- package/src/modules/cli/github/impl.ts +0 -169
- package/src/modules/cli/github/interface.ts +0 -29
- package/src/modules/cli/index.ts +0 -16
- package/src/modules/common/crypto/impl.ts +0 -173
- package/src/modules/common/crypto/interface.ts +0 -20
- package/src/modules/common/index.ts +0 -19
- package/src/modules/common/kep-map-store/impl.ts +0 -47
- package/src/modules/common/kep-map-store/interface.ts +0 -9
- package/src/modules/common/os-platform/caller-info.ts +0 -152
- package/src/modules/common/os-platform/impl.ts +0 -91
- package/src/modules/common/os-platform/interface.ts +0 -16
- package/src/modules/common/os-platform/virtual-hid/index.ts +0 -12
- package/src/modules/common/os-platform/virtual-hid/linux.ts +0 -7
- package/src/modules/common/os-platform/virtual-hid/mac.ts +0 -150
- package/src/modules/common/os-platform/virtual-hid/windows.ts +0 -7
- package/src/modules/common/ssh-config/impl.ts +0 -130
- package/src/modules/common/ssh-config/interface.ts +0 -33
- package/src/modules/ssh-agent/index.ts +0 -13
- package/src/modules/ssh-agent/session/agent-notes.md +0 -210
- package/src/modules/ssh-agent/session/design.md +0 -2
- package/src/modules/ssh-agent/session/html-refactor.md +0 -23
- package/src/modules/ssh-agent/session/impl.ts +0 -328
- package/src/modules/ssh-agent/session/interface-rpc.ts +0 -24
- package/src/modules/ssh-agent/session/interface.ts +0 -62
- package/src/modules/ssh-agent/session/passkey-prf-page.ts +0 -224
- package/src/modules/ssh-agent/session/prf-flow.ts +0 -152
- package/src/modules/ssh-agent/ssh-agent/impl.ts +0 -136
- package/src/modules/ssh-agent/ssh-agent/interface.ts +0 -9
- package/src/scripts/build.ts +0 -504
- package/src/scripts/check-npm-version.ts +0 -7
- package/src/scripts/command-launcher.js.ejs +0 -168
- package/src/scripts/kill-existing-agent.ts +0 -22
- package/src/scripts/publish.ts +0 -47
- package/src/scripts/windows-bin-shims.md +0 -1104
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
import type { Option } from "effect";
|
|
3
|
-
import type { EffectGen } from "effective-modules";
|
|
4
|
-
import type { SSHPubkey } from "@/modules/common/crypto/impl";
|
|
5
|
-
|
|
6
|
-
export const SSHConfigSection = Schema.Struct({
|
|
7
|
-
HostName: Schema.String,
|
|
8
|
-
User: Schema.String,
|
|
9
|
-
IdentityFile: Schema.optional(Schema.String),
|
|
10
|
-
AddKeysToAgent: Schema.optional(Schema.String),
|
|
11
|
-
IdentitiesOnly: Schema.optional(Schema.String),
|
|
12
|
-
IdentityAgent: Schema.optional(Schema.String),
|
|
13
|
-
});
|
|
14
|
-
export type SSHConfigSection = Schema.Schema.Type<typeof SSHConfigSection>;
|
|
15
|
-
export type SSHConfigHosts = Record<string, SSHConfigSection>;
|
|
16
|
-
|
|
17
|
-
export interface WriteHostInput {
|
|
18
|
-
section: SSHConfigSection;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface WritePubkeyInput {
|
|
22
|
-
name: string;
|
|
23
|
-
comment: Option.Option<string>;
|
|
24
|
-
pubkey: SSHPubkey;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ISSHConfig {
|
|
28
|
-
getSSHConfig(): EffectGen<SSHConfigHosts, string>;
|
|
29
|
-
writeHost(input: WriteHostInput): EffectGen<void, string>;
|
|
30
|
-
writePubkey(input: WritePubkeyInput): EffectGen<{pubkeyPath: string}, string>;
|
|
31
|
-
listPubkeyFiles(): EffectGen<{name: string, content: string}[], string>;
|
|
32
|
-
getPubkeyPath(name: string): string;
|
|
33
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { interfaces } from "effective-modules";
|
|
2
|
-
import type { ISSHAgent } from "./ssh-agent/interface";
|
|
3
|
-
import type { ISession } from "./session/interface";
|
|
4
|
-
|
|
5
|
-
export enum AgentModules {
|
|
6
|
-
SSHAgent = "SSHAgent",
|
|
7
|
-
Session = "Session"
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const agentModules = interfaces<AgentModules, {
|
|
11
|
-
SSHAgent: ISSHAgent;
|
|
12
|
-
Session: ISession;
|
|
13
|
-
}>(AgentModules);
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
# Why This Tool Exists
|
|
2
|
-
|
|
3
|
-
`webauthn-sk-ecdsa-sha2-nistp256@openssh.com` is the SSH key type that would allow platform passkeys (Face ID, Touch ID, Windows Hello) to sign SSH auth and commits directly — no PRF, no HKDF, no agent needed. If GitHub supported it, this tool would be largely unnecessary for the basic case.
|
|
4
|
-
|
|
5
|
-
GitHub only supports the `sk-` prefix variants (FIDO2 hardware security keys like YubiKey), not the `webauthn-sk-` platform passkey variant. So we need the PRF → HKDF → derived key path instead.
|
|
6
|
-
|
|
7
|
-
Even if GitHub added `webauthn-sk-` support, per-repo scoping would remain valuable: a standard GitHub SSH key grants access to all your repos. Deploy keys + per-repo HKDF derivation is the only way to get true repo-level auth scoping, critical for the Jive multi-repo context.
|
|
8
|
-
|
|
9
|
-
## Long-Term Vision (not current scope)
|
|
10
|
-
|
|
11
|
-
Jive could eventually bypass SSH entirely: accept native passkey WebAuthn signatures directly, with the challenge encoding the repo (for push/pull) or the commit SHA (for signing). Jive verifies server-side and writes to the repo via its GitHub App permissions. No SSH agent, no key derivation, no deploy keys — passkey IS the auth primitive.
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
# Key Architecture: Per-Repo HKDF Derivation
|
|
16
|
-
|
|
17
|
-
Each repo gets a distinct SSH keypair derived from the passkey PRF seed:
|
|
18
|
-
- `HKDF(passkey_seed, repo_url)` → SSH private key for that repo
|
|
19
|
-
- Public key is registered as a GitHub **deploy key** on that specific repo
|
|
20
|
-
- Agent maintains a persistent registry: `pubkey → repo_path` (populated at setup time)
|
|
21
|
-
|
|
22
|
-
**Security property:** A signature from repo X's derived key is valid ONLY for repo X's deploy key. Even a sophisticated attacker who gets the agent to sign for repo X's key cannot use that signature to access repo Y. The per-repo key derivation IS the security boundary — no binary replacement or cwd trick escapes it.
|
|
23
|
-
|
|
24
|
-
**Signing request flow:**
|
|
25
|
-
1. Request arrives with pubkey P + data to sign
|
|
26
|
-
2. Agent looks up registry: pubkey P → repo R
|
|
27
|
-
3. Agent triggers WebAuthn PRF to get seed (if not cached)
|
|
28
|
-
4. HKDF(seed, R) → private key K; verify K's pubkey = P
|
|
29
|
-
5. Sign and return
|
|
30
|
-
|
|
31
|
-
**Session manifest:** list of `(pubkey, operation_type, count)` tuples. Incoming requests match by pubkey — unambiguous, one pubkey per repo. No cwd lookup needed.
|
|
32
|
-
|
|
33
|
-
**Full security stack against a sophisticated local attacker:**
|
|
34
|
-
- Push/pull auth: scoped to specific repo via deploy key — signature for repo X is cryptographically useless for repo Y
|
|
35
|
-
- Commit signing: separate WebAuthn tap, browser shows exact commit content being signed — attacker can't forge a signed commit without the user seeing it
|
|
36
|
-
- Verified commits enforced server-side: repo rejects unsigned/unverified commits
|
|
37
|
-
|
|
38
|
-
An attacker would need to both obtain an auth signature AND trick the user into approving a commit signing request for malicious content — both visible in the browser. Very hard to pull off silently at any scale.
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
# Setup Flow (per repo)
|
|
43
|
-
|
|
44
|
-
`ozy git passkey-to-ssh setup`:
|
|
45
|
-
1. If no derived pubkey registered for this repo:
|
|
46
|
-
- Trigger WebAuthn PRF to get seed (registration assertion, creates passkey if needed)
|
|
47
|
-
- HKDF(seed, repo_url) → keypair
|
|
48
|
-
- Browser OAuth flow to get short-lived GitHub token (admin:repo scope, setup only, discarded after)
|
|
49
|
-
- Register pubkey as deploy key via GitHub API (`POST /repos/{owner}/{repo}/keys`)
|
|
50
|
-
- Store pubkey → repo_path in local agent registry
|
|
51
|
-
- Write pubkey to local git config (`user.signingkey`)
|
|
52
|
-
- Write git config settings (sshCommand, gpg.format, gpg.ssh.program)
|
|
53
|
-
2. If pubkey already registered → authentication flow (start agent/session)
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
# Git Config (per-repo local)
|
|
58
|
-
|
|
59
|
-
```
|
|
60
|
-
# 1. Network Authn (Pushes/Pulls)
|
|
61
|
-
git config local.core.sshCommand "env SSH_AUTH_SOCK='/path/to/your/custom/agent.sock' ssh"
|
|
62
|
-
|
|
63
|
-
# 2. Tell Git to use SSH format for signing
|
|
64
|
-
git config local.gpg.format "ssh"
|
|
65
|
-
|
|
66
|
-
# 3. Provide the literal public key (Triggers agent mode automatically)
|
|
67
|
-
git config local.user.signingkey "ssh-ed25519 AAAAC3Nz..."
|
|
68
|
-
|
|
69
|
-
# 4. Force ssh-keygen to read your custom agent using OpenSSH options
|
|
70
|
-
git config local.gpg.ssh.program "ssh-keygen -o IdentityAgent=/path/to/your/custom/agent.sock"
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Both auth and signing flow through the same custom SSH agent socket.
|
|
74
|
-
Pubkey lives in local git config (`user.signingkey`).
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
# Browser / Agent Communication
|
|
79
|
-
|
|
80
|
-
- Agent starts a localhost HTTP server on a random port
|
|
81
|
-
- Agent opens browser to `http://localhost:<port>/`
|
|
82
|
-
- Page performs WebAuthn PRF assertion
|
|
83
|
-
- Page encrypts PRF seed with agent's ephemeral public key
|
|
84
|
-
- Page `fetch()` POSTs encrypted seed to `POST /seed` on same server
|
|
85
|
-
- Agent responds with success or failure JSON
|
|
86
|
-
- On success, page instructs user to close the tab
|
|
87
|
-
- Agent tears down HTTP server, derives SSH key via HKDF from decrypted seed
|
|
88
|
-
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
# Agent Ephemeral Keypair
|
|
92
|
-
|
|
93
|
-
- Generated fresh on each invocation of `getAndVerifySigningAndDeployKey` (never touches disk)
|
|
94
|
-
- Used only to encrypt the PRF seed during browser→agent transfer
|
|
95
|
-
- Wiped after session ends along with derived SSH key
|
|
96
|
-
- ECDH P-256 + HKDF → AES-GCM (X25519 not supported in Bun compiled binaries)
|
|
97
|
-
|
|
98
|
-
**Security properties of the ephemeral keypair:**
|
|
99
|
-
|
|
100
|
-
- **Prevents PRF sniffing:** An attacker who intercepts the encrypted POST to `/seed` cannot recover the raw PRF seed. The ciphertext is decryptable only by the agent that generated the keypair for that session.
|
|
101
|
-
- **Prevents cross-session replay:** The AES-GCM key is derived from `ECDH(agent_private, browser_ephemeral_public)`. A new invocation generates a fresh agent keypair, so the ECDH shared secret differs — replaying a captured encrypted POST to a new server produces a different AES key and decryption fails with an authentication tag mismatch. The encrypted seed is cryptographically bound to the specific agent public key that was live during that session.
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
# Session Model
|
|
106
|
-
|
|
107
|
-
Sessions are **explicitly pre-declared** by the orchestrator (e.g. jive), not accumulated dynamically.
|
|
108
|
-
|
|
109
|
-
Flow:
|
|
110
|
-
1. Orchestrator starts a session, declaring the full manifest of expected operations upfront
|
|
111
|
-
2. Agent opens browser; browser shows exactly what will be signed
|
|
112
|
-
3. User approves with a single tap
|
|
113
|
-
4. Agent derives SSH key, services only the declared requests
|
|
114
|
-
5. Any request outside the declared manifest is rejected outright
|
|
115
|
-
6. After all declared requests are serviced (or on cancel), key is wiped and session ends
|
|
116
|
-
|
|
117
|
-
**Why not timeout-based accumulation:** A malicious script with shell access could inject additional signing requests into an open session window. With pre-declaration, the agent has a fixed allowlist.
|
|
118
|
-
|
|
119
|
-
**Unrecognized requests:** If a signing request arrives that doesn't match any active session's manifest, the agent treats it as a single-request session and initiates its own WebAuthn flow for just that one signature. This handles:
|
|
120
|
-
- Direct git usage (no orchestrator started a session)
|
|
121
|
-
- Malicious injection (user sees a single isolated request in the browser and can deny it)
|
|
122
|
-
|
|
123
|
-
## What Can Be Pre-Declared?
|
|
124
|
-
|
|
125
|
-
For **SSH auth (push/pull)**: the challenge bytes are random and server-generated — the exact signature input isn't known in advance. Can only pre-declare: destination host + operation type (push vs pull) + repo path. Agent enforces that only declared host/repo/operation combinations are serviced, up to declared counts.
|
|
126
|
-
|
|
127
|
-
**SSH auth request identifiability:** For N repos all pointing at the same SSH host (e.g. github.com), the agent sees N signing requests that are effectively indistinguishable — each is "sign this opaque blob as user `git`". The repo path is not part of SSH auth signing data; it only appears later in the git protocol layer after authentication. The session identifier is a fresh hash per connection (different ephemeral keys), so blobs differ, but their meaning is the same from the agent's perspective.
|
|
128
|
-
|
|
129
|
-
Implication: manifest matching for SSH auth is host + username + operation type + **count**. The agent allows exactly N such requests and rejects any beyond that. Individual slot-to-repo mapping is not enforceable at the agent level.
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
# Registration vs Authentication Flow
|
|
134
|
-
|
|
135
|
-
`ozy git passkey-to-ssh setup` (per repo):
|
|
136
|
-
- If no pubkey in local git config → **registration flow**: create passkey, derive SSH key, store pubkey in git config, configure git settings above
|
|
137
|
-
- If pubkey exists → **authentication flow**: start agent if not running, either use cached key or open browser for PRF assertion
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
# Context Derivation at Sign Time
|
|
142
|
-
|
|
143
|
-
The agent derives repo context by reading the **peer PID** of the Unix socket / named pipe connection — not from the signing data itself, and not via filesystem walk.
|
|
144
|
-
|
|
145
|
-
**Why not parse the signing data:**
|
|
146
|
-
- Commit signing: the SSHSIG blob contains `SHA-512(commit object)`. The commit object has tree hash, parent hashes, author, committer, message — but NOT the repo name. The final commit hash also isn't present (it depends on the signature, so it doesn't exist yet).
|
|
147
|
-
- Push/pull SSH auth: the challenge bytes are random and server-generated. Repo path doesn't appear until after authentication at the git protocol layer.
|
|
148
|
-
|
|
149
|
-
**Why not walk the filesystem:**
|
|
150
|
-
- Slow and costly on large filesystems; hangs on network mounts.
|
|
151
|
-
- Doesn't handle users moving their repo around.
|
|
152
|
-
- Attacker with local filesystem access could plant a git object with the same tree hash into another repo to cause a false match.
|
|
153
|
-
|
|
154
|
-
**Peer PID approach:**
|
|
155
|
-
- The git process that triggered the signing request is alive on the socket. Its cwd IS the repo.
|
|
156
|
-
- Get peer PID from the socket connection, read cwd, read `remote.origin.url` from that repo's git config.
|
|
157
|
-
- Platform APIs: `SO_PEERCRED` (Linux), `LOCAL_PEERCRED`/`getpeereid` (macOS), `GetNamedPipeClientProcessId` (Windows).
|
|
158
|
-
|
|
159
|
-
**What each signing key tells you:**
|
|
160
|
-
- Deploy key used → push/pull for the repo that key was registered to (per-repo key, unambiguous)
|
|
161
|
-
- Signing key used → commit signing; use peer PID to identify which repo's git process is requesting
|
|
162
|
-
|
|
163
|
-
**What the tree hash means:**
|
|
164
|
-
- The tree SHA in a commit is a content-addressed merkle tree over the entire working directory. Signing the commit object is effectively signing that full snapshot plus lineage (parent hashes) and authorship metadata.
|
|
165
|
-
- A malicious commit requires a different tree hash (different file contents), so showing the tree hash in the browser prompt is meaningful — but not human-readable. Showing the commit message + author is more practical UX.
|
|
166
|
-
|
|
167
|
-
---
|
|
168
|
-
|
|
169
|
-
# Credential Map
|
|
170
|
-
|
|
171
|
-
The `pubkey → credentialId` map on disk is kept for two reasons:
|
|
172
|
-
|
|
173
|
-
1. **Pre-selecting an existing passkey in the WebAuthn PRF flow** — avoids creating a new passkey registration on every session; the credential ID is passed to `navigator.credentials.get()` as `allowCredentials`.
|
|
174
|
-
2. **Responding to `REQUEST_IDENTITIES` from the SSH client** — `listKeys()` returns all known deploy pubkeys so git knows which keys the agent holds.
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
# Flows mapped out
|
|
178
|
-
|
|
179
|
-
1. User clones repo (`ozy git setup <owner/repo>`)
|
|
180
|
-
1. Note that in this case, the deploy key needs to be registered before git clone can be run
|
|
181
|
-
1. client calls getOrSetupKeys(credentialId?, repoIds?[])
|
|
182
|
-
1. agent will ask for a new credential if missing. In addition it will derive X deploy keys and 1 signing key
|
|
183
|
-
1. agent will both return these keys and store them in the map from key to credential
|
|
184
|
-
1. client registers deploy keys and signing key if any didn't exist yet
|
|
185
|
-
1. client starts session([Action { user, clone, repo, pubkey }])
|
|
186
|
-
1. agent fails request if the pubkey isn't present in the map file
|
|
187
|
-
1. client calls git clone using the deploy key
|
|
188
|
-
1. ssh agent passes this action to main agent
|
|
189
|
-
1. agent will fail if pubkey mapping isn't present
|
|
190
|
-
1. if there's no matching session, agent will create single use session with just the action
|
|
191
|
-
1. User sets up already cloned repo
|
|
192
|
-
1. client calls getOrSetupKeys(credentialId?, repoIds?[])
|
|
193
|
-
1. User pushes/pulls
|
|
194
|
-
1. client starts session
|
|
195
|
-
1. User signs commit
|
|
196
|
-
|
|
197
|
-
SSH Agent lists keys by finding the PID talking to it and only returning that key
|
|
198
|
-
There can only be one active session at a time. Browser will learn of sneaky attempts to bypass this
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
# Known attack vectors
|
|
203
|
-
|
|
204
|
-
We use PID hierarchy tied to a session to ensure that only git commands spawned from the trusted PID root can send actions to the active signing session. This works fine on Unix like systems since PID trees are controlled by the kernel, but on Windows someone can set the parent PID to whatever they want and also the PID is not reassigned to some global root when the parent exists.
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
|
-
# Open Questions
|
|
209
|
-
|
|
210
|
-
- For SSH auth pre-declaration: is host+repo+operation granularity sufficient?
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
Passkey to SSH is a utility which will configure a repo to do git authn and commit signing
|
|
2
|
-
with a passkey. The general flow is that an agent will be running which stores a standard ssh private key in memory. The key is derived from a passkey PRF extension obtained deterministic seed. When the ssh key needs to reassembled, the agent will open a browser which will perform a prf passkey request using the web's WebAuthn APIs, the seed will be encrypted with the agent's public key then sent over the wire to the agent. The agent will decrypt with its private key then with the decrypted seed use an HKDF to derive the ssh private key. Once in memory the key will be used to sign requests, when the machine restarts the ssh private key will have been lost since it only lives in memory. Also when the agent boots up it generates a new self keypair for the encryption / decryption of ssh key seeds. This keypair also only ever live in memory. There will either be utilities used to replace the commands which git calls out to for git commit signing (git config's gpg.ssh.program) and ssh authn (git config's core.sshCommand), or we'll find a way to work with existing default ssh tools.
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# HTML Refactor: passkey-prf-page
|
|
2
|
-
|
|
3
|
-
## Problem
|
|
4
|
-
`passkey-prf-page.ts` generates HTML+JS via string templates. The embedded browser script (escape noise, no type checking, no IDE support) makes it hard to read and maintain.
|
|
5
|
-
|
|
6
|
-
## Proposed approach
|
|
7
|
-
Split into three layers:
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
passkey-prf-browser.ts ← real TypeScript; browser DOM/WebAuthn code with full IDE support
|
|
11
|
-
↓ Bun.build (added to build.ts before main binary compile)
|
|
12
|
-
passkey-prf-browser-bundle.ts ← generated file: export const BROWSER_BUNDLE = "..."
|
|
13
|
-
↓ imported by
|
|
14
|
-
passkey-prf-page.tsx ← JSX template; <script>{BROWSER_BUNDLE}</script>
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## build.ts change
|
|
18
|
-
Before compiling `ozy-signing-agent`, add one `Bun.build` call targeting `passkey-prf-browser.ts` with `target: 'browser'` and `minify: true`. Write the first output's text to `passkey-prf-browser-bundle.ts` as a generated string constant. The generated file should be gitignored or marked as generated.
|
|
19
|
-
|
|
20
|
-
## Benefits
|
|
21
|
-
- Browser code can import shared constants (e.g. `FUTURE_TOOL_NAME`) — TypeScript catches mismatches
|
|
22
|
-
- HTML template is plain JSX — no backslash escaping, readable structure
|
|
23
|
-
- `<script>` block is just `{BROWSER_BUNDLE}` — no structural noise
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
import { Deferred, Effect, Option, pipe, Result, Layer } from "effect";
|
|
2
|
-
import { effunct, implementing, type EffectGen } from "effective-modules";
|
|
3
|
-
import { SSHPubkey, CredentialId, SSHKeyPair } from "@/modules/common/crypto/impl";
|
|
4
|
-
import { renderCallerTree } from "@/common/render-caller-tree";
|
|
5
|
-
import { BunHttpServer } from "@effect/platform-bun";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { agentModules } from "@/modules/ssh-agent";
|
|
8
|
-
import { commonModules } from "@/modules/common";
|
|
9
|
-
import { SessionError, type ISession, type SetupInput, SetupOutput, StartSessionInput, type SignInput } from "./interface";
|
|
10
|
-
import { AgentRpcGroup } from "./interface-rpc";
|
|
11
|
-
import { prfFlow, PrfInput, type PrfResult } from "./prf-flow";
|
|
12
|
-
import { AGENT_PORT_FILE_PATH, CURRENT_VERSION, DEFAULT_SESSION_TIMEOUT_SECONDS } from "@/common/constants";
|
|
13
|
-
import { HttpRouter, HttpServer } from "effect/unstable/http";
|
|
14
|
-
import { RpcServer, RpcSerialization } from "effect/unstable/rpc";
|
|
15
|
-
import { writeFileSync } from "node:fs";
|
|
16
|
-
import { using } from "@/common/effective-modules-extensions";
|
|
17
|
-
|
|
18
|
-
export interface ActiveSession {
|
|
19
|
-
id: string;
|
|
20
|
-
definition: StartSessionInput;
|
|
21
|
-
// The indices match the expected sign requests which were received
|
|
22
|
-
receivedSignRequests: {
|
|
23
|
-
deferredResponse: Deferred.Deferred<Buffer, SessionError>;
|
|
24
|
-
signRequest: SignInput;
|
|
25
|
-
}[];
|
|
26
|
-
totalReceived: number;
|
|
27
|
-
pubkey: string;
|
|
28
|
-
credentialId: string;
|
|
29
|
-
completed: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const { OSPlatform, Crypto, KeyMapStore } = commonModules;
|
|
33
|
-
const { Session } = agentModules;
|
|
34
|
-
|
|
35
|
-
const SIGN_REQUESTS_COLLECTION_TIMEOUT_SECONDS = 3;
|
|
36
|
-
|
|
37
|
-
export class SessionImpl extends implementing(Session).uses(OSPlatform, Crypto, KeyMapStore) implements ISession {
|
|
38
|
-
private activeSession: Option.Option<ActiveSession> = Option.none();
|
|
39
|
-
private keyCache = new Map<string, SSHKeyPair>();
|
|
40
|
-
|
|
41
|
-
private *abortSession(session: ActiveSession, onlyCheckRequestsAccumulated: boolean): EffectGen<void> {
|
|
42
|
-
if (session.completed) return;
|
|
43
|
-
let timeout = session.definition.timeoutSeconds;
|
|
44
|
-
if (onlyCheckRequestsAccumulated) {
|
|
45
|
-
const expected = session.definition.expectedSignRequests.length;
|
|
46
|
-
const actual = session.receivedSignRequests.length;
|
|
47
|
-
if (expected === actual) {
|
|
48
|
-
yield* Effect.log(`All sign requests were received for session ${session.id} within ${SIGN_REQUESTS_COLLECTION_TIMEOUT_SECONDS} seconds`);
|
|
49
|
-
return;
|
|
50
|
-
} else {
|
|
51
|
-
yield* Effect.log(`After ${SIGN_REQUESTS_COLLECTION_TIMEOUT_SECONDS} seconds only ${actual}/${expected} sign requests were received for session ${session.id}. Aborting`);
|
|
52
|
-
timeout = SIGN_REQUESTS_COLLECTION_TIMEOUT_SECONDS;
|
|
53
|
-
}
|
|
54
|
-
} else {
|
|
55
|
-
yield* Effect.log(`Aborting session ${session.id} after ${timeout} seconds`);
|
|
56
|
-
}
|
|
57
|
-
if (Option.isSome(this.activeSession) && this.activeSession.value.id === session.id) {
|
|
58
|
-
this.activeSession = Option.none();
|
|
59
|
-
}
|
|
60
|
-
// Fail all the sign requests.
|
|
61
|
-
const error = SessionError.cases.SessionTimedOut.make({
|
|
62
|
-
seconds: timeout
|
|
63
|
-
});
|
|
64
|
-
session.completed = true;
|
|
65
|
-
for (let i = 0; i < session.definition.expectedSignRequests.length; ++i) {
|
|
66
|
-
const maybeReceived = Option.fromNullishOr(session.receivedSignRequests[i]);
|
|
67
|
-
if (Option.isNone(maybeReceived)) continue;
|
|
68
|
-
const { deferredResponse } = maybeReceived.value;
|
|
69
|
-
yield* Deferred.fail(deferredResponse, error);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
*startSession({expectedCommonAncestorPID, expectedSignRequests, timeoutSeconds, pubkey}: StartSessionInput): EffectGen<void, SessionError> {
|
|
74
|
-
if (Option.isSome(this.activeSession)) {
|
|
75
|
-
const activeId = this.activeSession.value.id;
|
|
76
|
-
yield* Effect.log(`Blocked attempt to start session while ongoing active session ${activeId}`);
|
|
77
|
-
return yield* Effect.fail(SessionError.cases.InterruptingSession.make({ id: activeId }));
|
|
78
|
-
}
|
|
79
|
-
const maybeCredential = yield* pipe(
|
|
80
|
-
effunct(this.dependencies.KeyMapStore.getCredentialByPubkey)(SSHPubkey.fromAuthorizedKey(pubkey)),
|
|
81
|
-
Effect.catch(err => Effect.fail(SessionError.cases.InternalError.make({
|
|
82
|
-
reason: `Failed to read from key map store: ${err}`
|
|
83
|
-
})))
|
|
84
|
-
)
|
|
85
|
-
if (Option.isNone(maybeCredential)) {
|
|
86
|
-
return yield* Effect.fail(SessionError.cases.PubkeyNotRegistered.make({
|
|
87
|
-
pubkey
|
|
88
|
-
}));
|
|
89
|
-
}
|
|
90
|
-
const id = randomUUID();
|
|
91
|
-
const session: ActiveSession = {
|
|
92
|
-
definition: {expectedCommonAncestorPID, expectedSignRequests, timeoutSeconds, pubkey},
|
|
93
|
-
receivedSignRequests: [],
|
|
94
|
-
id,
|
|
95
|
-
totalReceived: 0,
|
|
96
|
-
pubkey,
|
|
97
|
-
credentialId: maybeCredential.value,
|
|
98
|
-
completed: false
|
|
99
|
-
};
|
|
100
|
-
this.activeSession = Option.some(session);
|
|
101
|
-
const n = expectedSignRequests.length;
|
|
102
|
-
yield* Effect.log(`Starting session ${id} for ${new CredentialId(maybeCredential.value).humanReadableName} (${maybeCredential.value}). Expecting ${n} sign request${n === 1 ? '' : 's'}`);
|
|
103
|
-
// 2 timeouts.
|
|
104
|
-
// - One for just collecting all requests in the session.
|
|
105
|
-
Effect.runFork(pipe(
|
|
106
|
-
effunct(this.abortSession)(session, true),
|
|
107
|
-
Effect.delay(`${SIGN_REQUESTS_COLLECTION_TIMEOUT_SECONDS} seconds`)
|
|
108
|
-
));
|
|
109
|
-
// - Another which abandons the session if the actual prf flow hasn't happened yet
|
|
110
|
-
Effect.runFork(pipe(
|
|
111
|
-
effunct(this.abortSession)(session, false),
|
|
112
|
-
Effect.delay(`${timeoutSeconds} seconds`)
|
|
113
|
-
));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private isSignRequestInSession(session: ActiveSession, signRequest: SignInput): Option.Option<{index: number}> {
|
|
117
|
-
if (session.pubkey !== signRequest.pubkey.authorizedKey) {
|
|
118
|
-
return Option.none();
|
|
119
|
-
}
|
|
120
|
-
const trustedParentPid = session.definition.expectedCommonAncestorPID;
|
|
121
|
-
let foundTrustedParentPid = false;
|
|
122
|
-
for (let i = 0; i < signRequest.callerTree.length; ++i) {
|
|
123
|
-
if (signRequest.callerTree[i]!.pid === trustedParentPid) {
|
|
124
|
-
foundTrustedParentPid = true;
|
|
125
|
-
break;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
if (!foundTrustedParentPid) {
|
|
129
|
-
return Option.none();
|
|
130
|
-
}
|
|
131
|
-
for (let i = 0; i < session.definition.expectedSignRequests.length; ++i) {
|
|
132
|
-
const { expectedCallerChain } = session.definition.expectedSignRequests[i]!;
|
|
133
|
-
let j = 0;
|
|
134
|
-
for (; j < expectedCallerChain.length; ++j) {
|
|
135
|
-
const expected = expectedCallerChain[j]!;
|
|
136
|
-
const actual = signRequest.callerTree[j];
|
|
137
|
-
if (
|
|
138
|
-
actual.command !== expected.command ||
|
|
139
|
-
Option.getOrNull(actual.directory) !== Option.getOrNull(expected.directory)
|
|
140
|
-
) {
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (j === expectedCallerChain.length) {
|
|
145
|
-
// Found match
|
|
146
|
-
return Option.some({index: i});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return Option.none();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
private *cacheKey(keyPair: SSHKeyPair, credentialId: CredentialId, cacheMinutes: number): EffectGen<void> {
|
|
153
|
-
const pubkey = keyPair.pubkey.authorizedKey;
|
|
154
|
-
this.keyCache.set(pubkey, keyPair);
|
|
155
|
-
Effect.runFork(pipe(
|
|
156
|
-
Effect.sync(() => { this.keyCache.delete(pubkey); }),
|
|
157
|
-
Effect.andThen(Effect.log(`Cache entry revoked for ${pubkey} (${credentialId.humanReadableName}) after ${cacheMinutes} minutes`)),
|
|
158
|
-
Effect.delay(`${cacheMinutes} minutes`)
|
|
159
|
-
));
|
|
160
|
-
yield* Effect.log(`Cached key ${pubkey} (${credentialId.humanReadableName}) for ${cacheMinutes} minutes`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private *resolveAllSignRequests(session: ActiveSession): EffectGen<void> {
|
|
164
|
-
// By setting it to none here, we technically do allow parallel sessions, the
|
|
165
|
-
// caveat is there's an expectation that all requests for a session are retrieved
|
|
166
|
-
// in a short time frame. In the future we may have a backlog of non matching signing requests
|
|
167
|
-
// and sessions
|
|
168
|
-
this.activeSession = Option.none();
|
|
169
|
-
const credential = new CredentialId(session.credentialId);
|
|
170
|
-
|
|
171
|
-
const cachedKeyPair = this.keyCache.get(session.pubkey);
|
|
172
|
-
let prfResult: Result.Result<PrfResult, SessionError>;
|
|
173
|
-
if (cachedKeyPair) {
|
|
174
|
-
yield* Effect.log(`Using cached key for session ${session.id}`);
|
|
175
|
-
prfResult = Result.succeed({ keyPair: cachedKeyPair, credentialId: credential });
|
|
176
|
-
} else {
|
|
177
|
-
prfResult = yield* pipe(
|
|
178
|
-
effunct(prfFlow)(PrfInput.DerivePubkeyForRequests({session})),
|
|
179
|
-
Effect.scoped,
|
|
180
|
-
Effect.provide(this.context),
|
|
181
|
-
Effect.result
|
|
182
|
-
);
|
|
183
|
-
if (Result.isSuccess(prfResult)) {
|
|
184
|
-
const { keyPair } = prfResult.success;
|
|
185
|
-
const derivedPubkey = keyPair.pubkey.authorizedKey;
|
|
186
|
-
if (derivedPubkey !== session.pubkey) {
|
|
187
|
-
yield* Effect.log(`PRF derived pubkey ${derivedPubkey} but session expects ${session.pubkey}`);
|
|
188
|
-
prfResult = Result.fail(SessionError.cases.InternalError.make({
|
|
189
|
-
reason: `PRF derived pubkey ${derivedPubkey} does not match session pubkey ${session.pubkey}`
|
|
190
|
-
}));
|
|
191
|
-
} else if (prfResult.success.cacheMinutes !== undefined) {
|
|
192
|
-
yield* this.cacheKey(keyPair, credential, prfResult.success.cacheMinutes);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
session.completed = true;
|
|
198
|
-
if (Result.isFailure(prfResult)) {
|
|
199
|
-
yield* Effect.log(`PRF flow failed for session ${session.id}: ${JSON.stringify(prfResult.failure)}`);
|
|
200
|
-
} else {
|
|
201
|
-
yield* Effect.log(`PRF flow succeeded for session ${session.id}`);
|
|
202
|
-
}
|
|
203
|
-
let totalSigned = 0;
|
|
204
|
-
for (const req of session.receivedSignRequests) {
|
|
205
|
-
if (Result.isSuccess(prfResult)) {
|
|
206
|
-
const { keyPair } = prfResult.success;
|
|
207
|
-
const sigResult = yield* Effect.result(Effect.try({
|
|
208
|
-
try: () => keyPair.sign(req.signRequest.data),
|
|
209
|
-
catch: (e) => SessionError.cases.InternalError.make({ reason: String(e) }),
|
|
210
|
-
}));
|
|
211
|
-
if (Result.isSuccess(sigResult)) {
|
|
212
|
-
totalSigned++;
|
|
213
|
-
yield* Effect.log(`(${totalSigned}/${session.receivedSignRequests.length}) Signed ${req.signRequest.data.length} bytes for session ${session.id}`);
|
|
214
|
-
yield* Deferred.succeed(req.deferredResponse, sigResult.success);
|
|
215
|
-
} else {
|
|
216
|
-
yield* Effect.log(`Signing failed for session ${session.id}: ${JSON.stringify(sigResult.failure)}`);
|
|
217
|
-
yield* Deferred.fail(req.deferredResponse, sigResult.failure);
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
yield* Deferred.fail(req.deferredResponse, prfResult.failure);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
*sign(signRequest: SignInput): EffectGen<Buffer, SessionError> {
|
|
227
|
-
const {pubkey, callerTree} = signRequest;
|
|
228
|
-
// On the no active session case. We create one.
|
|
229
|
-
if (Option.isNone(this.activeSession)) {
|
|
230
|
-
yield* Effect.log(`Received sign request when no session exists. Chain:\n${renderCallerTree(callerTree.slice(-3))}`);
|
|
231
|
-
yield* this.startSession({
|
|
232
|
-
expectedCommonAncestorPID: callerTree[callerTree.length - 1].pid,
|
|
233
|
-
expectedSignRequests: [{
|
|
234
|
-
expectedCallerChain: callerTree
|
|
235
|
-
}],
|
|
236
|
-
timeoutSeconds: DEFAULT_SESSION_TIMEOUT_SECONDS,
|
|
237
|
-
pubkey: pubkey.authorizedKey
|
|
238
|
-
})
|
|
239
|
-
}
|
|
240
|
-
if (Option.isNone(this.activeSession)) {
|
|
241
|
-
return yield* Effect.fail(SessionError.cases.InternalError.make({
|
|
242
|
-
reason: "Invariant violation. Active session not set after calling startSession"
|
|
243
|
-
}));
|
|
244
|
-
}
|
|
245
|
-
const session = this.activeSession.value;
|
|
246
|
-
const maybeIndex = this.isSignRequestInSession(session, signRequest);
|
|
247
|
-
if (Option.isNone(maybeIndex)) {
|
|
248
|
-
// TODO: we could instead create a queue of backed-up sign requests and give
|
|
249
|
-
// a very short window for each session to collect its requests.
|
|
250
|
-
return yield* Effect.fail(SessionError.cases.InterruptingSession.make({
|
|
251
|
-
id: session.id
|
|
252
|
-
}));
|
|
253
|
-
}
|
|
254
|
-
const {index} = maybeIndex.value;
|
|
255
|
-
const deferredResponse = yield* Deferred.make<Buffer, SessionError>();
|
|
256
|
-
session.receivedSignRequests[index] = {
|
|
257
|
-
deferredResponse,
|
|
258
|
-
signRequest
|
|
259
|
-
}
|
|
260
|
-
session.totalReceived++;
|
|
261
|
-
const totalExpected = session.definition.expectedSignRequests.length;
|
|
262
|
-
yield* Effect.log(`Got matching signature request. ${totalExpected - session.totalReceived} remaining`);
|
|
263
|
-
if (session.totalReceived > totalExpected) {
|
|
264
|
-
return yield* Effect.fail(SessionError.cases.InternalError.make({
|
|
265
|
-
reason: `Only expected to get ${totalExpected} signature requests. Got ${session.totalReceived} instead`
|
|
266
|
-
}));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (session.totalReceived === totalExpected) {
|
|
270
|
-
yield* Effect.log(`All ${totalExpected} expected signature requests received for session ${session.id}. Initiating passkey verification.`);
|
|
271
|
-
yield* this.resolveAllSignRequests(session);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return yield* Deferred.await(deferredResponse);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
*setup(input: SetupInput): EffectGen<SetupOutput, SessionError> {
|
|
278
|
-
const { keyPair, credentialId: derivedCredentialId, cacheMinutes } = yield* pipe(
|
|
279
|
-
effunct(prfFlow)(PrfInput.DerivePubkeyOnly({ pubkey: input.pubkey, credentialId: input.credentialId, username: input.username })),
|
|
280
|
-
Effect.scoped,
|
|
281
|
-
Effect.provide(this.context)
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
const derivedPubkey = keyPair.pubkey.authorizedKey;
|
|
285
|
-
|
|
286
|
-
if (Option.isSome(input.pubkey) && derivedPubkey !== input.pubkey.value) {
|
|
287
|
-
return yield* Effect.fail(SessionError.cases.PubkeyMismatch.make({ expected: input.pubkey.value, derived: derivedPubkey }));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
yield* Effect.log(`Derived key for ${derivedCredentialId.humanReadableName} (${derivedCredentialId.base58})`);
|
|
291
|
-
|
|
292
|
-
yield* pipe(
|
|
293
|
-
effunct(this.dependencies.KeyMapStore.addKey)(keyPair.pubkey, derivedCredentialId.base58),
|
|
294
|
-
Effect.mapError(reason => SessionError.cases.InternalError.make({ reason }))
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
if (cacheMinutes !== undefined) {
|
|
298
|
-
yield* this.cacheKey(keyPair, derivedCredentialId, cacheMinutes);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return { pubkey: derivedPubkey, credentialId: derivedCredentialId.base58 };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
public static makeRpcLayer() {
|
|
305
|
-
const writePortFile = Layer.effectDiscard(
|
|
306
|
-
Effect.gen(function* () {
|
|
307
|
-
const server = yield* HttpServer.HttpServer;
|
|
308
|
-
if (server.address._tag !== "TcpAddress") {
|
|
309
|
-
return Effect.die(`Invariant server address of ${JSON.stringify(server.address)}`);
|
|
310
|
-
}
|
|
311
|
-
writeFileSync(AGENT_PORT_FILE_PATH, server.address.port.toString());
|
|
312
|
-
})
|
|
313
|
-
);
|
|
314
|
-
return pipe(
|
|
315
|
-
RpcServer.layerHttp({ group: AgentRpcGroup, path: "/rpc", protocol: "http" }),
|
|
316
|
-
Layer.provideMerge(AgentRpcGroup.toLayer({
|
|
317
|
-
GetVersion: () => Effect.succeed({ version: CURRENT_VERSION }),
|
|
318
|
-
StartSession: using(Session).startSession,
|
|
319
|
-
Setup: using(Session).setup,
|
|
320
|
-
})),
|
|
321
|
-
HttpRouter.serve,
|
|
322
|
-
Layer.provideMerge(writePortFile),
|
|
323
|
-
Layer.provideMerge(BunHttpServer.layer({ port: 0 })),
|
|
324
|
-
Layer.provideMerge(RpcSerialization.layerJson),
|
|
325
|
-
Layer.provideMerge(this.Layer)
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
3
|
-
import { SessionError, SetupInput, SetupOutput, StartSessionInput } from "./interface";
|
|
4
|
-
|
|
5
|
-
const GetVersionRpc = Rpc.make("GetVersion", {
|
|
6
|
-
payload: {},
|
|
7
|
-
success: Schema.Struct({ version: Schema.String }),
|
|
8
|
-
error: Schema.Never,
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
const SetupRpc = Rpc.make("Setup", {
|
|
12
|
-
payload: SetupInput,
|
|
13
|
-
success: SetupOutput,
|
|
14
|
-
error: SessionError,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const StartSessionRpc = Rpc.make("StartSession", {
|
|
18
|
-
payload: StartSessionInput,
|
|
19
|
-
success: Schema.Void,
|
|
20
|
-
error: SessionError,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export const AgentRpcGroup = RpcGroup.make(GetVersionRpc, StartSessionRpc, SetupRpc);
|
|
24
|
-
export type AgentRpcGroup = typeof AgentRpcGroup;
|