ncrypted 0.1.2 → 0.2.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/cli.js +53 -6
- package/dist/client.d.ts +82 -1
- package/dist/client.js +173 -16
- package/dist/crypto.d.ts +21 -0
- package/dist/crypto.js +61 -0
- package/dist/destinations.d.ts +35 -0
- package/dist/destinations.js +146 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/localRequests.d.ts +13 -0
- package/dist/localRequests.js +25 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,10 +11,16 @@ Usage:
|
|
|
11
11
|
ncrypted read <link> [--passphrase X]
|
|
12
12
|
ncrypted status <id> --receipt <token>
|
|
13
13
|
ncrypted burn <id> --receipt <token>
|
|
14
|
+
ncrypted vercel-env <link> --key NAME [--project P] [--team T] [--target production --target preview]
|
|
15
|
+
write a secret straight into a Vercel env var (never printed)
|
|
16
|
+
ncrypted dotenv <link> --key NAME [--path .env]
|
|
17
|
+
ncrypted request "<what you need>" [--ttl 60m] ask a human for a secret; prints a link for them
|
|
18
|
+
ncrypted receive <id> (--vercel-key NAME [--project P] | --dotenv-key NAME [--path .env] | --print) [--wait 120]
|
|
14
19
|
ncrypted wallet new create a payer wallet at ~/.config/ncrypted/wallet.key
|
|
15
20
|
ncrypted wallet show the payer address and its USDC balance on Base
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
The first 3 secrets per day are free (TTL up to 24h, no email). After that, creating a secret needs a Base
|
|
23
|
+
wallet holding a little USDC (no ETH needed). The key is read from
|
|
18
24
|
NCRYPTED_WALLET_PRIVATE_KEY, else NCRYPTED_WALLET_FILE, else ~/.config/ncrypted/wallet.key.
|
|
19
25
|
NCRYPTED_URL overrides the API origin.`;
|
|
20
26
|
function parseTtl(v) {
|
|
@@ -43,12 +49,21 @@ async function main() {
|
|
|
43
49
|
note: { type: "string" },
|
|
44
50
|
text: { type: "string" },
|
|
45
51
|
receipt: { type: "string" },
|
|
52
|
+
key: { type: "string" },
|
|
53
|
+
project: { type: "string" },
|
|
54
|
+
team: { type: "string" },
|
|
55
|
+
target: { type: "string", multiple: true },
|
|
56
|
+
path: { type: "string" },
|
|
57
|
+
wait: { type: "string" },
|
|
58
|
+
"vercel-key": { type: "string" },
|
|
59
|
+
"dotenv-key": { type: "string" },
|
|
60
|
+
print: { type: "boolean" },
|
|
46
61
|
json: { type: "boolean" },
|
|
47
62
|
help: { type: "boolean", short: "h" },
|
|
48
63
|
},
|
|
49
64
|
});
|
|
50
65
|
const [cmd, arg] = positionals;
|
|
51
|
-
const client = new Ncrypted();
|
|
66
|
+
const client = new Ncrypted({ client: "cli" });
|
|
52
67
|
const out = (v) => console.log(values.json ? JSON.stringify(v) : typeof v === "string" ? v : JSON.stringify(v, null, 2));
|
|
53
68
|
switch (values.help ? "help" : cmd) {
|
|
54
69
|
case "create": {
|
|
@@ -74,6 +89,35 @@ async function main() {
|
|
|
74
89
|
await client.burn(arg, values.receipt);
|
|
75
90
|
out({ burned: true });
|
|
76
91
|
break;
|
|
92
|
+
case "vercel-env":
|
|
93
|
+
if (!arg || !values.key)
|
|
94
|
+
throw new Error("Usage: ncrypted vercel-env <link> --key NAME [--project P] [--target production]");
|
|
95
|
+
out(await client.deliver(arg, { type: "vercel_env", key: values.key, project: values.project, team: values.team, targets: values.target }, { passphrase: values.passphrase }));
|
|
96
|
+
break;
|
|
97
|
+
case "dotenv":
|
|
98
|
+
if (!arg || !values.key)
|
|
99
|
+
throw new Error("Usage: ncrypted dotenv <link> --key NAME [--path .env]");
|
|
100
|
+
out(await client.deliver(arg, { type: "dotenv", key: values.key, path: values.path }, { passphrase: values.passphrase }));
|
|
101
|
+
break;
|
|
102
|
+
case "request":
|
|
103
|
+
if (!arg)
|
|
104
|
+
throw new Error('Usage: ncrypted request "<what you need>" [--ttl 60m]');
|
|
105
|
+
out(await client.requestSecret(arg, { ttl: parseTtl(values.ttl) ?? 3600 }));
|
|
106
|
+
break;
|
|
107
|
+
case "receive": {
|
|
108
|
+
if (!arg)
|
|
109
|
+
throw new Error("Usage: ncrypted receive <id> (--vercel-key NAME | --dotenv-key NAME | --print)");
|
|
110
|
+
const destination = values["vercel-key"]
|
|
111
|
+
? { type: "vercel_env", key: values["vercel-key"], project: values.project, team: values.team, targets: values.target }
|
|
112
|
+
: values["dotenv-key"]
|
|
113
|
+
? { type: "dotenv", key: values["dotenv-key"], path: values.path }
|
|
114
|
+
: undefined;
|
|
115
|
+
if (!destination && !values.print)
|
|
116
|
+
throw new Error("Choose where the value goes: --vercel-key, --dotenv-key, or --print");
|
|
117
|
+
const res = await client.fulfillRequest(arg, { destination, waitSeconds: Number(parseTtl(values.wait) ?? 120) });
|
|
118
|
+
out(res.status === "received" ? res.value : res);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
77
121
|
case "wallet": {
|
|
78
122
|
if (arg === "new") {
|
|
79
123
|
const { address, path } = await createWallet().catch((e) => {
|
|
@@ -81,15 +125,18 @@ async function main() {
|
|
|
81
125
|
throw new Error(`A wallet already exists at ${walletPath()}`);
|
|
82
126
|
throw e;
|
|
83
127
|
});
|
|
84
|
-
out({ address, path, next: "Send a little USDC on Base to this address. Each secret costs 0.005 USDC; no ETH needed." });
|
|
128
|
+
out({ address, path, fundUrl: client.fundUrl(address), next: "Send a little USDC on Base to this address (or open fundUrl). Each secret costs 0.005 USDC; no ETH needed." });
|
|
85
129
|
break;
|
|
86
130
|
}
|
|
87
131
|
const key = normalizeKey(process.env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
const { freeRemaining, freePerDay } = await client.quota();
|
|
133
|
+
if (!key) {
|
|
134
|
+
out({ wallet: null, freeRemainingToday: freeRemaining, freePerDay, next: "Run `npx ncrypted wallet new` to pay for more than the free tier." });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
90
137
|
const address = privateKeyToAccount(key).address;
|
|
91
138
|
const usdc = await usdcBalance(address);
|
|
92
|
-
out({ address, usdc,
|
|
139
|
+
out({ address, usdc, paidSecretsAvailable: Math.floor(usdc / 0.005), freeRemainingToday: freeRemaining });
|
|
93
140
|
break;
|
|
94
141
|
}
|
|
95
142
|
default:
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type Destination, type DeliveryResult } from "./destinations.js";
|
|
1
2
|
export declare const DEFAULT_BASE_URL = "https://ncrypted.ai";
|
|
2
3
|
/** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
|
|
3
4
|
export declare const MAX_PRICE = "$0.01";
|
|
5
|
+
export declare const SDK_VERSION = "0.2.0";
|
|
4
6
|
export interface NcryptedOptions {
|
|
5
7
|
/** API origin. Defaults to NCRYPTED_URL or the hosted service. */
|
|
6
8
|
baseUrl?: string;
|
|
@@ -11,6 +13,8 @@ export interface NcryptedOptions {
|
|
|
11
13
|
privateKey?: string;
|
|
12
14
|
/** Bring your own x402-paying fetch instead of a private key. */
|
|
13
15
|
paymentFetch?: typeof fetch;
|
|
16
|
+
/** Integration label sent as x-ncrypted-client for anonymous usage stats, e.g. "ai-sdk". Default "sdk". */
|
|
17
|
+
client?: string;
|
|
14
18
|
}
|
|
15
19
|
export interface CreateOptions {
|
|
16
20
|
/** Lifetime in seconds, 300 to 604800 (7 days). Default 86400. */
|
|
@@ -21,6 +25,8 @@ export interface CreateOptions {
|
|
|
21
25
|
emailTo?: string;
|
|
22
26
|
/** Optional short note shown in the email, e.g. "DB password for staging". Never put the secret here. */
|
|
23
27
|
emailNote?: string;
|
|
28
|
+
/** Skip the free tier and always pay (implied by emailTo). */
|
|
29
|
+
requirePayment?: boolean;
|
|
24
30
|
}
|
|
25
31
|
export interface CreatedSecret {
|
|
26
32
|
id: string;
|
|
@@ -32,6 +38,19 @@ export interface CreatedSecret {
|
|
|
32
38
|
emailed: boolean;
|
|
33
39
|
/** On-chain settlement transaction hash, if reported. */
|
|
34
40
|
paymentTx?: string;
|
|
41
|
+
/** True when created under the free tier (no payment). */
|
|
42
|
+
free: boolean;
|
|
43
|
+
/** Free secrets left today, when created for free. */
|
|
44
|
+
freeRemaining?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface Quota {
|
|
47
|
+
freePerDay: number;
|
|
48
|
+
freeRemaining: number;
|
|
49
|
+
/** Longest TTL (seconds) allowed for free secrets. */
|
|
50
|
+
freeMaxTtl: number;
|
|
51
|
+
price: string;
|
|
52
|
+
currency: string;
|
|
53
|
+
network: string;
|
|
35
54
|
}
|
|
36
55
|
export type SecretStatus = "pending" | "read" | "burned" | "expired";
|
|
37
56
|
export declare class NcryptedError extends Error {
|
|
@@ -45,8 +64,70 @@ export declare class Ncrypted {
|
|
|
45
64
|
constructor(opts?: NcryptedOptions);
|
|
46
65
|
/** Resolves the paying fetch lazily, so a missing or bad wallet only affects create(), never reads. */
|
|
47
66
|
private payer;
|
|
48
|
-
|
|
67
|
+
private headers;
|
|
68
|
+
/**
|
|
69
|
+
* Encrypts locally and stores the ciphertext, returning a one-time link. The first few secrets per day are free
|
|
70
|
+
* (TTL up to 24h, no email); after that each costs 0.005 USDC via x402 from the configured wallet.
|
|
71
|
+
*/
|
|
49
72
|
create(secret: string, opts?: CreateOptions): Promise<CreatedSecret>;
|
|
73
|
+
/**
|
|
74
|
+
* Reads a one-time link and writes the value straight into a destination (Vercel env var, .env file), so it never
|
|
75
|
+
* passes through the caller (e.g. an LLM). The destination is checked before the secret is consumed. If writing still
|
|
76
|
+
* fails afterwards, the value is re-saved to a new one-time link, which is returned in the error.
|
|
77
|
+
*/
|
|
78
|
+
deliver(link: string, destination: Destination, opts?: {
|
|
79
|
+
passphrase?: string;
|
|
80
|
+
}): Promise<DeliveryResult>;
|
|
81
|
+
private deliverOrRescue;
|
|
82
|
+
/**
|
|
83
|
+
* Starts a secret request: returns a link for a human, who pastes the value into the page. It's encrypted in their
|
|
84
|
+
* browser to a key that only exists on this machine. Free. Complete it with fulfillRequest().
|
|
85
|
+
*/
|
|
86
|
+
requestSecret(label: string, opts?: {
|
|
87
|
+
ttl?: number;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
id: string;
|
|
90
|
+
url: string;
|
|
91
|
+
expiresAt: string;
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* Waits up to waitSeconds for the human to answer a request. With a destination, the value is written there and
|
|
95
|
+
* never returned; without one, the value is returned. Resolves { status: "pending" } if not answered yet.
|
|
96
|
+
*/
|
|
97
|
+
fulfillRequest(id: string, opts?: {
|
|
98
|
+
destination?: Destination;
|
|
99
|
+
waitSeconds?: number;
|
|
100
|
+
}): Promise<{
|
|
101
|
+
status: "pending";
|
|
102
|
+
expiresAt: string;
|
|
103
|
+
} | {
|
|
104
|
+
status: "delivered";
|
|
105
|
+
result: DeliveryResult;
|
|
106
|
+
} | {
|
|
107
|
+
status: "received";
|
|
108
|
+
value: string;
|
|
109
|
+
}>;
|
|
110
|
+
/** Link to a page where the wallet's owner can send it USDC on Base. */
|
|
111
|
+
fundUrl(address: string, amountUsdc?: number): string;
|
|
112
|
+
/**
|
|
113
|
+
* Sets up the local payer wallet if there isn't one. Returns its address and a funding link for the owner; funding
|
|
114
|
+
* needs only the address. With backupToOwner, the private key is also put into a one-time link (24h) for the owner,
|
|
115
|
+
* so they can recover or withdraw the funds. The key itself is never returned.
|
|
116
|
+
*/
|
|
117
|
+
setupWallet(opts?: {
|
|
118
|
+
amountUsdc?: number;
|
|
119
|
+
backupToOwner?: boolean;
|
|
120
|
+
}): Promise<{
|
|
121
|
+
created: boolean;
|
|
122
|
+
address: string;
|
|
123
|
+
usdc: number;
|
|
124
|
+
fundUrl: string;
|
|
125
|
+
keyFile: string;
|
|
126
|
+
backupLink?: string;
|
|
127
|
+
backupError?: string;
|
|
128
|
+
}>;
|
|
129
|
+
/** Free-tier allowance left today and the paid price. */
|
|
130
|
+
quota(): Promise<Quota>;
|
|
50
131
|
/** Sends the link by email once. Allowed only for the creator and only once per secret. */
|
|
51
132
|
email(id: string, receiptToken: string, link: string, to: string, note?: string): Promise<void>;
|
|
52
133
|
/** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
|
package/dist/client.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
|
|
2
2
|
import { ExactEvmScheme } from "@x402/evm";
|
|
3
3
|
import { privateKeyToAccount } from "viem/accounts";
|
|
4
|
-
import { encrypt, decrypt, parseLink } from "./crypto.js";
|
|
5
|
-
import {
|
|
4
|
+
import { encrypt, decrypt, parseLink, generateRequestKeypair, openSealed } from "./crypto.js";
|
|
5
|
+
import { prepareDestination } from "./destinations.js";
|
|
6
|
+
import { saveLocalRequest, loadLocalRequest, removeLocalRequest } from "./localRequests.js";
|
|
7
|
+
import { normalizeKey, readWalletKey, createWallet, usdcBalance, walletPath } from "./wallet.js";
|
|
8
|
+
import { privateKeyToAccount as accountOf } from "viem/accounts";
|
|
6
9
|
export const DEFAULT_BASE_URL = "https://ncrypted.ai";
|
|
7
10
|
/** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
|
|
8
11
|
export const MAX_PRICE = "$0.01";
|
|
12
|
+
export const SDK_VERSION = "0.2.0";
|
|
9
13
|
export class NcryptedError extends Error {
|
|
10
14
|
status;
|
|
11
15
|
constructor(message, status) {
|
|
@@ -40,6 +44,8 @@ async function errorFrom(res) {
|
|
|
40
44
|
}
|
|
41
45
|
return new NcryptedError(msg, res.status);
|
|
42
46
|
}
|
|
47
|
+
const NO_WALLET = `Run \`npx ncrypted wallet new\` to create a payer wallet at ${"~/.config/ncrypted/wallet.key"}, send it a little USDC on Base ` +
|
|
48
|
+
"(0.005 per secret, no ETH needed), or set NCRYPTED_WALLET_PRIVATE_KEY.";
|
|
43
49
|
/** Settlement attempts per create. A failed settlement never moves funds, so retrying is safe. */
|
|
44
50
|
const SETTLE_ATTEMPTS = 3;
|
|
45
51
|
export class Ncrypted {
|
|
@@ -69,28 +75,38 @@ export class Ncrypted {
|
|
|
69
75
|
throw e;
|
|
70
76
|
}));
|
|
71
77
|
}
|
|
72
|
-
|
|
78
|
+
headers(extra = {}) {
|
|
79
|
+
return { "x-ncrypted-client": `${this.opts.client ?? "sdk"}/${SDK_VERSION}`, ...extra };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Encrypts locally and stores the ciphertext, returning a one-time link. The first few secrets per day are free
|
|
83
|
+
* (TTL up to 24h, no email); after that each costs 0.005 USDC via x402 from the configured wallet.
|
|
84
|
+
*/
|
|
73
85
|
async create(secret, opts = {}) {
|
|
74
86
|
const payFetch = await this.payer();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
const requirePayment = opts.requirePayment || !!opts.emailTo;
|
|
88
|
+
if (!payFetch && requirePayment) {
|
|
89
|
+
throw new Error(`${opts.emailTo ? "Email delivery needs a paid secret." : "requirePayment is set but no wallet is configured."} ${NO_WALLET}`);
|
|
78
90
|
}
|
|
79
91
|
const { payload, key } = await encrypt(secret, opts.passphrase);
|
|
80
92
|
const body = { ...payload, ttl: opts.ttl ?? 86400 };
|
|
93
|
+
// Email requires a paid secret, so skip the free tier when it's requested.
|
|
94
|
+
const headers = this.headers({ "content-type": "application/json", ...(requirePayment ? { "x-ncrypted-no-free": "1" } : {}) });
|
|
81
95
|
// The link is only knowable after the server assigns an id, so email goes in a second, free call.
|
|
82
96
|
let res;
|
|
83
97
|
for (let attempt = 1;; attempt++) {
|
|
84
|
-
res = await payFetch(`${this.baseUrl}/api/v1/secrets`, {
|
|
85
|
-
method: "POST",
|
|
86
|
-
headers: { "content-type": "application/json" },
|
|
87
|
-
body: JSON.stringify(body),
|
|
88
|
-
});
|
|
98
|
+
res = await (payFetch ?? fetch)(`${this.baseUrl}/api/v1/secrets`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
89
99
|
// Retry only when the payment was signed but settlement failed on-chain (e.g. a facilitator RPC hiccup).
|
|
90
100
|
if (res.status === 402 && settlementOf(res)?.success === false && attempt < SETTLE_ATTEMPTS)
|
|
91
101
|
continue;
|
|
92
102
|
break;
|
|
93
103
|
}
|
|
104
|
+
if (res.status === 402 && !payFetch) {
|
|
105
|
+
const perDay = res.headers.get("x-ncrypted-free-per-day") ?? "3";
|
|
106
|
+
const maxTtl = Number(res.headers.get("x-ncrypted-free-max-ttl") ?? 86400);
|
|
107
|
+
const why = (opts.ttl ?? 86400) > maxTtl ? `Free secrets can last at most ${maxTtl / 3600}h.` : `The ${perDay} free secrets per day are used up.`;
|
|
108
|
+
throw new NcryptedError(`${why} ${NO_WALLET}`, 402);
|
|
109
|
+
}
|
|
94
110
|
if (!res.ok)
|
|
95
111
|
throw await errorFrom(res);
|
|
96
112
|
const data = (await res.json());
|
|
@@ -101,13 +117,154 @@ export class Ncrypted {
|
|
|
101
117
|
await this.email(data.id, data.receiptToken, link, opts.emailTo, opts.emailNote);
|
|
102
118
|
emailed = true;
|
|
103
119
|
}
|
|
104
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
id: data.id,
|
|
122
|
+
link,
|
|
123
|
+
expiresAt: data.expiresAt,
|
|
124
|
+
receiptToken: data.receiptToken,
|
|
125
|
+
emailed,
|
|
126
|
+
paymentTx,
|
|
127
|
+
free: !!data.free,
|
|
128
|
+
...(data.free ? { freeRemaining: data.freeRemaining } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads a one-time link and writes the value straight into a destination (Vercel env var, .env file), so it never
|
|
133
|
+
* passes through the caller (e.g. an LLM). The destination is checked before the secret is consumed. If writing still
|
|
134
|
+
* fails afterwards, the value is re-saved to a new one-time link, which is returned in the error.
|
|
135
|
+
*/
|
|
136
|
+
async deliver(link, destination, opts = {}) {
|
|
137
|
+
const target = await prepareDestination(destination);
|
|
138
|
+
const value = await this.read(link, opts.passphrase);
|
|
139
|
+
return this.deliverOrRescue(value, target);
|
|
140
|
+
}
|
|
141
|
+
async deliverOrRescue(value, target) {
|
|
142
|
+
try {
|
|
143
|
+
return await target.deliver(value);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
let rescue = "";
|
|
147
|
+
try {
|
|
148
|
+
const saved = await this.create(value, { ttl: 3600 });
|
|
149
|
+
rescue = ` The value was re-saved to a new one-time link (1h): ${saved.link}`;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
rescue = " Re-saving the value also failed; ask for it again.";
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Delivery failed: ${e.message}.${rescue}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Starts a secret request: returns a link for a human, who pastes the value into the page. It's encrypted in their
|
|
159
|
+
* browser to a key that only exists on this machine. Free. Complete it with fulfillRequest().
|
|
160
|
+
*/
|
|
161
|
+
async requestSecret(label, opts = {}) {
|
|
162
|
+
const { publicKey, privateKey } = await generateRequestKeypair();
|
|
163
|
+
const res = await fetch(`${this.baseUrl}/api/v1/requests`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
166
|
+
body: JSON.stringify({ publicKey, label, ttl: opts.ttl ?? 3600 }),
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok)
|
|
169
|
+
throw await errorFrom(res);
|
|
170
|
+
const data = (await res.json());
|
|
171
|
+
await saveLocalRequest({ id: data.id, origin: this.baseUrl, publicKey, privateKey, pollToken: data.pollToken, label, expiresAt: data.expiresAt });
|
|
172
|
+
return { id: data.id, url: `${data.url}#${publicKey}`, expiresAt: data.expiresAt };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Waits up to waitSeconds for the human to answer a request. With a destination, the value is written there and
|
|
176
|
+
* never returned; without one, the value is returned. Resolves { status: "pending" } if not answered yet.
|
|
177
|
+
*/
|
|
178
|
+
async fulfillRequest(id, opts = {}) {
|
|
179
|
+
const local = await loadLocalRequest(id);
|
|
180
|
+
const target = opts.destination ? await prepareDestination(opts.destination) : undefined;
|
|
181
|
+
const deadline = Date.now() + Math.min(opts.waitSeconds ?? 0, 300) * 1000;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const res = await fetch(`${local.origin}/api/v1/requests/${id}/collect`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: this.headers({ authorization: `Bearer ${local.pollToken}` }),
|
|
186
|
+
});
|
|
187
|
+
if (res.status === 410) {
|
|
188
|
+
await removeLocalRequest(id);
|
|
189
|
+
throw new NcryptedError("This request expired or was already collected.", 410);
|
|
190
|
+
}
|
|
191
|
+
if (!res.ok)
|
|
192
|
+
throw await errorFrom(res);
|
|
193
|
+
const body = (await res.json());
|
|
194
|
+
if (body.status === "fulfilled" && body.sealed) {
|
|
195
|
+
const value = await openSealed(body.sealed, local.privateKey, local.publicKey);
|
|
196
|
+
await removeLocalRequest(id);
|
|
197
|
+
if (!target)
|
|
198
|
+
return { status: "received", value };
|
|
199
|
+
return { status: "delivered", result: await this.deliverOrRescue(value, target) };
|
|
200
|
+
}
|
|
201
|
+
if (Date.now() >= deadline)
|
|
202
|
+
return { status: "pending", expiresAt: body.expiresAt ?? local.expiresAt };
|
|
203
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Link to a page where the wallet's owner can send it USDC on Base. */
|
|
207
|
+
fundUrl(address, amountUsdc = 1) {
|
|
208
|
+
return `${this.baseUrl}/fund?address=${address}&amount=${amountUsdc}`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Sets up the local payer wallet if there isn't one. Returns its address and a funding link for the owner; funding
|
|
212
|
+
* needs only the address. With backupToOwner, the private key is also put into a one-time link (24h) for the owner,
|
|
213
|
+
* so they can recover or withdraw the funds. The key itself is never returned.
|
|
214
|
+
*/
|
|
215
|
+
async setupWallet(opts = {}) {
|
|
216
|
+
const existing = normalizeKey(this.opts.privateKey ?? process.env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
|
|
217
|
+
let address;
|
|
218
|
+
let created = false;
|
|
219
|
+
if (existing)
|
|
220
|
+
address = accountOf(existing).address;
|
|
221
|
+
else {
|
|
222
|
+
try {
|
|
223
|
+
address = (await createWallet()).address;
|
|
224
|
+
created = true;
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
// Another process created it at the same moment; use that one.
|
|
228
|
+
if (e.code !== "EEXIST")
|
|
229
|
+
throw e;
|
|
230
|
+
address = accountOf((await readWalletKey())).address;
|
|
231
|
+
}
|
|
232
|
+
this.payFetch = undefined; // pick up the new wallet on the next create()
|
|
233
|
+
}
|
|
234
|
+
const result = {
|
|
235
|
+
created,
|
|
236
|
+
address,
|
|
237
|
+
usdc: await usdcBalance(address).catch(() => 0),
|
|
238
|
+
fundUrl: this.fundUrl(address, opts.amountUsdc ?? 1),
|
|
239
|
+
keyFile: existing && !(await readWalletKey()) ? "NCRYPTED_WALLET_PRIVATE_KEY" : walletPath(),
|
|
240
|
+
};
|
|
241
|
+
if (!opts.backupToOwner)
|
|
242
|
+
return result;
|
|
243
|
+
try {
|
|
244
|
+
const key = existing ?? (await readWalletKey());
|
|
245
|
+
const backup = await this.create(`ncrypted payer wallet ${address}\nPrivate key: ${key}\nNetwork: Base`, { ttl: 86_400 });
|
|
246
|
+
return { ...result, backupLink: backup.link };
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
return {
|
|
250
|
+
...result,
|
|
251
|
+
backupError: `Couldn't create the backup link yet (${e.message.split(".")[0]}). ` +
|
|
252
|
+
"Once the wallet is funded, run setup again with backup to create it.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Free-tier allowance left today and the paid price. */
|
|
257
|
+
async quota() {
|
|
258
|
+
const res = await fetch(`${this.baseUrl}/api/v1/quota`, { headers: this.headers() });
|
|
259
|
+
if (!res.ok)
|
|
260
|
+
throw await errorFrom(res);
|
|
261
|
+
return (await res.json());
|
|
105
262
|
}
|
|
106
263
|
/** Sends the link by email once. Allowed only for the creator and only once per secret. */
|
|
107
264
|
async email(id, receiptToken, link, to, note) {
|
|
108
265
|
const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/email`, {
|
|
109
266
|
method: "POST",
|
|
110
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${receiptToken}` },
|
|
267
|
+
headers: this.headers({ "content-type": "application/json", authorization: `Bearer ${receiptToken}` }),
|
|
111
268
|
body: JSON.stringify({ to, link, note }),
|
|
112
269
|
});
|
|
113
270
|
if (!res.ok)
|
|
@@ -116,7 +273,7 @@ export class Ncrypted {
|
|
|
116
273
|
/** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
|
|
117
274
|
async read(link, passphrase) {
|
|
118
275
|
const { origin, id, key } = parseLink(link);
|
|
119
|
-
const res = await fetch(`${origin}/api/v1/secrets/${id}/reveal`, { method: "POST" });
|
|
276
|
+
const res = await fetch(`${origin}/api/v1/secrets/${id}/reveal`, { method: "POST", headers: this.headers() });
|
|
120
277
|
if (res.status === 410 || res.status === 404) {
|
|
121
278
|
throw new NcryptedError("This secret has already been read, burned, or has expired.", 410);
|
|
122
279
|
}
|
|
@@ -128,7 +285,7 @@ export class Ncrypted {
|
|
|
128
285
|
/** Whether the secret has been read yet. Needs the creator's receipt token. */
|
|
129
286
|
async status(id, receiptToken) {
|
|
130
287
|
const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/status`, {
|
|
131
|
-
headers: { authorization: `Bearer ${receiptToken}` },
|
|
288
|
+
headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
|
|
132
289
|
});
|
|
133
290
|
if (!res.ok)
|
|
134
291
|
throw await errorFrom(res);
|
|
@@ -138,7 +295,7 @@ export class Ncrypted {
|
|
|
138
295
|
async burn(id, receiptToken) {
|
|
139
296
|
const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}`, {
|
|
140
297
|
method: "DELETE",
|
|
141
|
-
headers: { authorization: `Bearer ${receiptToken}` },
|
|
298
|
+
headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
|
|
142
299
|
});
|
|
143
300
|
if (!res.ok)
|
|
144
301
|
throw await errorFrom(res);
|
package/dist/crypto.d.ts
CHANGED
|
@@ -23,3 +23,24 @@ export declare function parseLink(link: string): {
|
|
|
23
23
|
id: string;
|
|
24
24
|
key: string;
|
|
25
25
|
};
|
|
26
|
+
export interface SealedPayload {
|
|
27
|
+
v: number;
|
|
28
|
+
epk: string;
|
|
29
|
+
iv: string;
|
|
30
|
+
ciphertext: string;
|
|
31
|
+
}
|
|
32
|
+
/** New X25519 keypair for a secret request. publicKey goes in the request link's #fragment; keep privateKey local. */
|
|
33
|
+
export declare function generateRequestKeypair(): Promise<{
|
|
34
|
+
publicKey: string;
|
|
35
|
+
privateKey: JsonWebKey;
|
|
36
|
+
}>;
|
|
37
|
+
/** Seals plaintext so only the holder of the request's private key can open it. Used by the request page. */
|
|
38
|
+
export declare function sealToPublicKey(plaintext: string, publicKey: string): Promise<SealedPayload>;
|
|
39
|
+
/** Opens a sealed payload with the request's private key. Throws if it was tampered with or sealed to another key. */
|
|
40
|
+
export declare function openSealed(sealed: SealedPayload, privateKey: JsonWebKey, publicKey: string): Promise<string>;
|
|
41
|
+
/** Splits a request link: https://host/r/{id}#{publicKey} */
|
|
42
|
+
export declare function parseRequestLink(link: string): {
|
|
43
|
+
origin: string;
|
|
44
|
+
id: string;
|
|
45
|
+
publicKey: string;
|
|
46
|
+
};
|
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