ncrypted 0.1.2 → 0.3.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/dist/crypto.js CHANGED
@@ -72,3 +72,64 @@ export function parseLink(link) {
72
72
  throw new Error("Not a valid ncrypted link (expected https://host/s/{id}#{key})");
73
73
  return { origin: url.origin, id: m[1], key };
74
74
  }
75
+ // ---- Secret requests: a human seals a value to the requesting agent's X25519 public key. ----
76
+ const REQUEST_INFO = new TextEncoder().encode("ncrypted request v1");
77
+ /** New X25519 keypair for a secret request. publicKey goes in the request link's #fragment; keep privateKey local. */
78
+ export async function generateRequestKeypair() {
79
+ const kp = (await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]));
80
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
81
+ return { publicKey: toB64url(raw), privateKey: await crypto.subtle.exportKey("jwk", kp.privateKey) };
82
+ }
83
+ async function requestKey(shared, epk, pk) {
84
+ const salt = new Uint8Array(epk.length + pk.length);
85
+ salt.set(epk);
86
+ salt.set(pk, epk.length);
87
+ const base = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
88
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: REQUEST_INFO }, base, { name: "AES-GCM", length: 256 }, false, [
89
+ "encrypt",
90
+ "decrypt",
91
+ ]);
92
+ }
93
+ /** Seals plaintext so only the holder of the request's private key can open it. Used by the request page. */
94
+ export async function sealToPublicKey(plaintext, publicKey) {
95
+ const data = new TextEncoder().encode(plaintext);
96
+ if (data.length === 0)
97
+ throw new Error("Secret is empty");
98
+ if (data.length > MAX_PLAINTEXT_BYTES)
99
+ throw new Error(`Secret exceeds ${MAX_PLAINTEXT_BYTES} bytes`);
100
+ const pk = fromB64url(publicKey);
101
+ if (pk.length !== 32)
102
+ throw new Error("Invalid request public key");
103
+ const recipient = await crypto.subtle.importKey("raw", pk, { name: "X25519" }, false, []);
104
+ const eph = (await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]));
105
+ const epk = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
106
+ const shared = await crypto.subtle.deriveBits({ name: "X25519", public: recipient }, eph.privateKey, 256);
107
+ const key = await requestKey(shared, epk, pk);
108
+ const iv = randomBytes(12);
109
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
110
+ return { v: VERSION, epk: toB64url(epk), iv: toB64url(iv), ciphertext: toB64url(ct) };
111
+ }
112
+ /** Opens a sealed payload with the request's private key. Throws if it was tampered with or sealed to another key. */
113
+ export async function openSealed(sealed, privateKey, publicKey) {
114
+ const priv = await crypto.subtle.importKey("jwk", privateKey, { name: "X25519" }, false, ["deriveBits"]);
115
+ const epk = fromB64url(sealed.epk);
116
+ const eph = await crypto.subtle.importKey("raw", epk, { name: "X25519" }, false, []);
117
+ const shared = await crypto.subtle.deriveBits({ name: "X25519", public: eph }, priv, 256);
118
+ const key = await requestKey(shared, epk, fromB64url(publicKey));
119
+ try {
120
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64url(sealed.iv) }, key, fromB64url(sealed.ciphertext));
121
+ return new TextDecoder().decode(pt);
122
+ }
123
+ catch {
124
+ throw new Error("Could not open the submitted secret: it was tampered with or sealed to a different key");
125
+ }
126
+ }
127
+ /** Splits a request link: https://host/r/{id}#{publicKey} */
128
+ export function parseRequestLink(link) {
129
+ const url = new URL(link);
130
+ const m = url.pathname.match(/^\/r\/([A-Za-z0-9_-]{16,64})\/?$/);
131
+ const publicKey = url.hash.slice(1);
132
+ if (!m || !publicKey)
133
+ throw new Error("Not a valid ncrypted request link (expected https://host/r/{id}#{publicKey})");
134
+ return { origin: url.origin, id: m[1], publicKey };
135
+ }
@@ -0,0 +1,35 @@
1
+ export type VercelTarget = "production" | "preview" | "development";
2
+ export interface VercelEnvDestination {
3
+ type: "vercel_env";
4
+ /** Env var name, e.g. STRIPE_SECRET_KEY. */
5
+ key: string;
6
+ /** Project name or id. Defaults to the project linked in <cwd>/.vercel/project.json. */
7
+ project?: string;
8
+ /** Team id (team_...) or slug. Defaults to the linked project's org. */
9
+ team?: string;
10
+ /** Default: production and preview. */
11
+ targets?: VercelTarget[];
12
+ /** Directory containing .vercel/project.json. Default: process.cwd(). */
13
+ cwd?: string;
14
+ comment?: string;
15
+ }
16
+ export interface DotenvDestination {
17
+ type: "dotenv";
18
+ key: string;
19
+ /** Path to the .env file. Default: ./.env */
20
+ path?: string;
21
+ }
22
+ export type Destination = VercelEnvDestination | DotenvDestination;
23
+ export interface DeliveryResult {
24
+ destination: string;
25
+ key: string;
26
+ details: Record<string, unknown>;
27
+ }
28
+ export interface PreparedDestination {
29
+ deliver(value: string): Promise<DeliveryResult>;
30
+ }
31
+ /**
32
+ * Validates a destination (auth, project access, names) without touching the secret.
33
+ * Call this before consuming a one-time secret so a bad destination doesn't destroy it.
34
+ */
35
+ export declare function prepareDestination(d: Destination): Promise<PreparedDestination>;
@@ -0,0 +1,146 @@
1
+ // Node-only destinations that receive a secret's value directly, so it never passes through the model.
2
+ import { readFile, writeFile, chmod } from "node:fs/promises";
3
+ import { execFile } from "node:child_process";
4
+ import { homedir, platform } from "node:os";
5
+ import { join, resolve, dirname } from "node:path";
6
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+ function checkKey(key) {
8
+ if (!KEY_RE.test(key))
9
+ throw new Error(`Invalid variable name "${key}" (letters, digits and _, not starting with a digit)`);
10
+ }
11
+ // ---------------- Vercel ----------------
12
+ function vercelAuthPath() {
13
+ if (process.env.VERCEL_AUTH_FILE)
14
+ return process.env.VERCEL_AUTH_FILE;
15
+ if (platform() === "darwin")
16
+ return join(homedir(), "Library", "Application Support", "com.vercel.cli", "auth.json");
17
+ if (platform() === "win32")
18
+ return join(process.env.APPDATA ?? homedir(), "com.vercel.cli", "auth.json");
19
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "com.vercel.cli", "auth.json");
20
+ }
21
+ /** VERCEL_TOKEN, else the Vercel CLI's login session (`vercel login`). */
22
+ async function vercelToken() {
23
+ if (process.env.VERCEL_TOKEN)
24
+ return process.env.VERCEL_TOKEN;
25
+ let auth;
26
+ try {
27
+ auth = JSON.parse(await readFile(vercelAuthPath(), "utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Not logged in to Vercel. Run `vercel login` (or set VERCEL_TOKEN) and try again.");
31
+ }
32
+ if (!auth.token)
33
+ throw new Error("Not logged in to Vercel. Run `vercel login` (or set VERCEL_TOKEN) and try again.");
34
+ if (auth.expiresAt && auth.expiresAt * (auth.expiresAt < 1e12 ? 1000 : 1) < Date.now() + 60_000) {
35
+ throw new Error("Your Vercel CLI session has expired. Run `vercel whoami` to refresh it (or set VERCEL_TOKEN) and try again.");
36
+ }
37
+ return auth.token;
38
+ }
39
+ async function linkedProject(cwd) {
40
+ try {
41
+ return JSON.parse(await readFile(join(cwd, ".vercel", "project.json"), "utf8"));
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ async function vercelApi(r, method, path, body) {
48
+ const res = await fetch(`https://api.vercel.com${path}${path.includes("?") ? "&" : "?"}${r.query}`, {
49
+ method,
50
+ headers: { authorization: `Bearer ${r.token}`, "content-type": "application/json" },
51
+ body: body === undefined ? undefined : JSON.stringify(body),
52
+ });
53
+ const data = (await res.json().catch(() => ({})));
54
+ if (!res.ok)
55
+ throw new Error(`Vercel API ${res.status}: ${data.error?.message ?? res.statusText}`);
56
+ return data;
57
+ }
58
+ async function resolveVercel(d) {
59
+ checkKey(d.key);
60
+ const token = await vercelToken();
61
+ const linked = await linkedProject(d.cwd ?? process.cwd());
62
+ const project = d.project ?? linked?.projectId;
63
+ if (!project)
64
+ throw new Error("No Vercel project given and no .vercel/project.json here. Pass `project` (name or id) or run `vercel link`.");
65
+ const team = d.team ?? (d.project ? undefined : linked?.orgId);
66
+ const query = team ? (team.startsWith("team_") ? `teamId=${encodeURIComponent(team)}` : `slug=${encodeURIComponent(team)}`) : "";
67
+ const targets = d.targets?.length ? [...new Set(d.targets)] : ["production", "preview"];
68
+ return { token, project, query, targets };
69
+ }
70
+ /** Checks auth and project access before a one-time secret is consumed. */
71
+ async function preflightVercel(d) {
72
+ const r = await resolveVercel(d);
73
+ await vercelApi(r, "GET", `/v9/projects/${encodeURIComponent(r.project)}`);
74
+ return r;
75
+ }
76
+ async function deliverVercel(value, d, r) {
77
+ // Sensitive vars can't target development; use encrypted there.
78
+ const sensitiveTargets = r.targets.filter((t) => t !== "development");
79
+ const entries = [
80
+ ...(sensitiveTargets.length ? [{ key: d.key, value, type: "sensitive", target: sensitiveTargets }] : []),
81
+ ...(r.targets.includes("development") ? [{ key: d.key, value, type: "encrypted", target: ["development"] }] : []),
82
+ ].map((e) => ({ ...e, comment: d.comment ?? "Set via ncrypted (value never shown to the agent)" }));
83
+ await vercelApi(r, "POST", `/v10/projects/${encodeURIComponent(r.project)}/env?upsert=true`, entries.length === 1 ? entries[0] : entries);
84
+ return {
85
+ destination: "vercel_env",
86
+ key: d.key,
87
+ details: {
88
+ project: r.project,
89
+ targets: r.targets,
90
+ type: sensitiveTargets.length ? "sensitive" : "encrypted",
91
+ note: "Redeploy for the new value to take effect.",
92
+ },
93
+ };
94
+ }
95
+ // ---------------- .env file ----------------
96
+ function quoteEnv(value) {
97
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value))
98
+ return value;
99
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
100
+ }
101
+ function gitIgnored(path) {
102
+ return new Promise((done) => execFile("git", ["check-ignore", "-q", path], { cwd: dirname(path) }, (err) => {
103
+ if (!err)
104
+ return done(true);
105
+ done(err.code === 1 ? false : undefined); // 128: not a git repo
106
+ }));
107
+ }
108
+ async function deliverDotenv(value, d) {
109
+ checkKey(d.key);
110
+ const path = resolve(d.path ?? ".env");
111
+ let text = "";
112
+ try {
113
+ text = await readFile(path, "utf8");
114
+ }
115
+ catch { }
116
+ const line = `${d.key}=${quoteEnv(value)}`;
117
+ const re = new RegExp(`^(export\\s+)?${d.key}=.*$`, "m");
118
+ text = re.test(text) ? text.replace(re, line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`;
119
+ await writeFile(path, text, { mode: 0o600 });
120
+ await chmod(path, 0o600);
121
+ const ignored = await gitIgnored(path);
122
+ return {
123
+ destination: "dotenv",
124
+ key: d.key,
125
+ details: {
126
+ path,
127
+ gitIgnored: ignored,
128
+ ...(ignored === false ? { warning: `${path} is NOT git-ignored. Add it to .gitignore before committing.` } : {}),
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Validates a destination (auth, project access, names) without touching the secret.
134
+ * Call this before consuming a one-time secret so a bad destination doesn't destroy it.
135
+ */
136
+ export async function prepareDestination(d) {
137
+ if (d.type === "vercel_env") {
138
+ const r = await preflightVercel(d);
139
+ return { deliver: (v) => deliverVercel(v, d, r) };
140
+ }
141
+ if (d.type === "dotenv") {
142
+ checkKey(d.key);
143
+ return { deliver: (v) => deliverDotenv(v, d) };
144
+ }
145
+ throw new Error(`Unknown destination type "${d.type}"`);
146
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
- export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES, type EncryptedPayload } from "./crypto.js";
2
+ export { encrypt, decrypt, parseLink, parseRequestLink, sealToPublicKey, MAX_PLAINTEXT_BYTES, type EncryptedPayload, type SealedPayload } from "./crypto.js";
3
+ export type { Destination, VercelEnvDestination, DotenvDestination, DeliveryResult, VercelTarget } from "./destinations.js";
3
4
  export { walletPath, createWallet, usdcBalance } from "./wallet.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export * from "./client.js";
2
- export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES } from "./crypto.js";
2
+ export { encrypt, decrypt, parseLink, parseRequestLink, sealToPublicKey, MAX_PLAINTEXT_BYTES } from "./crypto.js";
3
3
  export { walletPath, createWallet, usdcBalance } from "./wallet.js";
@@ -0,0 +1,13 @@
1
+ export interface LocalRequest {
2
+ id: string;
3
+ origin: string;
4
+ publicKey: string;
5
+ privateKey: JsonWebKey;
6
+ pollToken: string;
7
+ label: string;
8
+ expiresAt: string;
9
+ }
10
+ export declare function requestsDir(): string;
11
+ export declare function saveLocalRequest(r: LocalRequest): Promise<void>;
12
+ export declare function loadLocalRequest(id: string): Promise<LocalRequest>;
13
+ export declare const removeLocalRequest: (id: string) => Promise<void>;
@@ -0,0 +1,25 @@
1
+ // Node-only storage for the private keys of pending secret requests (one 0600 file per request).
2
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ export function requestsDir() {
6
+ return process.env.NCRYPTED_REQUESTS_DIR || join(homedir(), ".config", "ncrypted", "requests");
7
+ }
8
+ const fileFor = (id) => {
9
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(id))
10
+ throw new Error("Invalid request id");
11
+ return join(requestsDir(), `${id}.json`);
12
+ };
13
+ export async function saveLocalRequest(r) {
14
+ await mkdir(requestsDir(), { recursive: true, mode: 0o700 });
15
+ await writeFile(fileFor(r.id), JSON.stringify(r), { mode: 0o600 });
16
+ }
17
+ export async function loadLocalRequest(id) {
18
+ try {
19
+ return JSON.parse(await readFile(fileFor(id), "utf8"));
20
+ }
21
+ catch {
22
+ throw new Error(`No pending request ${id} on this machine (it was collected already, or created elsewhere).`);
23
+ }
24
+ }
25
+ export const removeLocalRequest = (id) => rm(fileFor(id), { force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncrypted",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "One-time, end-to-end encrypted secrets for AI agents. Pay 0.005 USDC per secret via x402; the note self-destructs after one read.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,10 @@
18
18
  "./wallet": {
19
19
  "types": "./dist/wallet.d.ts",
20
20
  "default": "./dist/wallet.js"
21
+ },
22
+ "./ai-sdk": {
23
+ "types": "./dist/ai-sdk.d.ts",
24
+ "default": "./dist/ai-sdk.js"
21
25
  }
22
26
  },
23
27
  "bin": {
@@ -32,13 +36,15 @@
32
36
  "node": ">=20"
33
37
  },
34
38
  "keywords": [
35
- "secrets",
36
- "one-time",
37
- "x402",
38
39
  "agents",
39
- "mcp",
40
+ "ai-sdk",
40
41
  "encryption",
41
- "usdc"
42
+ "mcp",
43
+ "one-time",
44
+ "secrets",
45
+ "usdc",
46
+ "vercel",
47
+ "x402"
42
48
  ],
43
49
  "scripts": {
44
50
  "build": "tsc -p tsconfig.json",
@@ -52,7 +58,9 @@
52
58
  "devDependencies": {
53
59
  "@types/node": "^24.0.0",
54
60
  "typescript": "^5.9.0",
55
- "vitest": "^5.0.5"
61
+ "vitest": "^5.0.5",
62
+ "ai": "^7.0.143",
63
+ "zod": "^4.6.7"
56
64
  },
57
65
  "repository": {
58
66
  "type": "git",
@@ -62,5 +70,17 @@
62
70
  "homepage": "https://github.com/Seidl-Family/ncrypted",
63
71
  "publishConfig": {
64
72
  "access": "public"
73
+ },
74
+ "peerDependencies": {
75
+ "ai": ">=5",
76
+ "zod": "^3.25.0 || ^4.0.0"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "ai": {
80
+ "optional": true
81
+ },
82
+ "zod": {
83
+ "optional": true
84
+ }
65
85
  }
66
86
  }