ncrypted 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seidl Family
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # ncrypted
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SDK and CLI for [ncrypted](https://github.com/Seidl-Family/ncrypted): one-time, end-to-end encrypted secrets for AI agents. 0.005 USDC per secret over x402 (Base); reading is free.
4
+
5
+ ```ts
6
+ import { Ncrypted } from "ncrypted";
7
+ const nc = new Ncrypted({ privateKey: process.env.NCRYPTED_WALLET_PRIVATE_KEY });
8
+ const { link } = await nc.create("hunter2", { ttl: 3600, emailTo: "ops@example.com" });
9
+ const value = await nc.read(link);
10
+ ```
11
+
12
+ ```sh
13
+ echo "hunter2" | npx ncrypted create --ttl 1h
14
+ npx ncrypted read "<link>"
15
+ ```
16
+
17
+ See the [main README](https://github.com/Seidl-Family/ncrypted) for details.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { Ncrypted } from "./client.js";
4
+ const HELP = `ncrypted - one-time, end-to-end encrypted secrets for agents (0.005 USDC per secret, reads free)
5
+
6
+ Usage:
7
+ ncrypted create [--ttl 1h] [--passphrase X] [--email to@x.com] [--note "..."] < secret.txt
8
+ ncrypted create --text "secret value"
9
+ ncrypted read <link> [--passphrase X]
10
+ ncrypted status <id> --receipt <token>
11
+ ncrypted burn <id> --receipt <token>
12
+
13
+ Env: NCRYPTED_WALLET_PRIVATE_KEY (Base wallet with USDC, needed for create), NCRYPTED_URL (optional)`;
14
+ function parseTtl(v) {
15
+ if (!v)
16
+ return undefined;
17
+ const m = v.match(/^(\d+)([smhd]?)$/);
18
+ if (!m)
19
+ throw new Error(`Bad --ttl "${v}" (use e.g. 30m, 1h, 7d)`);
20
+ return Number(m[1]) * { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2]];
21
+ }
22
+ async function readStdin() {
23
+ if (process.stdin.isTTY)
24
+ return "";
25
+ const chunks = [];
26
+ for await (const c of process.stdin)
27
+ chunks.push(c);
28
+ return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
29
+ }
30
+ async function main() {
31
+ const { positionals, values } = parseArgs({
32
+ allowPositionals: true,
33
+ options: {
34
+ ttl: { type: "string" },
35
+ passphrase: { type: "string" },
36
+ email: { type: "string" },
37
+ note: { type: "string" },
38
+ text: { type: "string" },
39
+ receipt: { type: "string" },
40
+ json: { type: "boolean" },
41
+ help: { type: "boolean", short: "h" },
42
+ },
43
+ });
44
+ const [cmd, arg] = positionals;
45
+ const client = new Ncrypted();
46
+ const out = (v) => console.log(values.json ? JSON.stringify(v) : typeof v === "string" ? v : JSON.stringify(v, null, 2));
47
+ switch (values.help ? "help" : cmd) {
48
+ case "create": {
49
+ const text = values.text ?? (await readStdin());
50
+ if (!text)
51
+ throw new Error("Provide the secret on stdin or with --text");
52
+ out(await client.create(text, { ttl: parseTtl(values.ttl), passphrase: values.passphrase, emailTo: values.email, emailNote: values.note }));
53
+ break;
54
+ }
55
+ case "read":
56
+ if (!arg)
57
+ throw new Error("Usage: ncrypted read <link>");
58
+ out(await client.read(arg, values.passphrase));
59
+ break;
60
+ case "status":
61
+ if (!arg || !values.receipt)
62
+ throw new Error("Usage: ncrypted status <id> --receipt <token>");
63
+ out(await client.status(arg, values.receipt));
64
+ break;
65
+ case "burn":
66
+ if (!arg || !values.receipt)
67
+ throw new Error("Usage: ncrypted burn <id> --receipt <token>");
68
+ await client.burn(arg, values.receipt);
69
+ out({ burned: true });
70
+ break;
71
+ default:
72
+ console.log(HELP);
73
+ }
74
+ }
75
+ main().catch((e) => {
76
+ console.error(`Error: ${e.message}`);
77
+ process.exit(1);
78
+ });
@@ -0,0 +1,56 @@
1
+ export declare const DEFAULT_BASE_URL = "https://ncrypted-ai.vercel.app";
2
+ /** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
3
+ export declare const MAX_PRICE = "$0.01";
4
+ export interface NcryptedOptions {
5
+ /** API origin. Defaults to NCRYPTED_URL or the hosted service. */
6
+ baseUrl?: string;
7
+ /** 0x-prefixed EVM private key of a wallet holding USDC on Base. Defaults to NCRYPTED_WALLET_PRIVATE_KEY. */
8
+ privateKey?: string;
9
+ /** Bring your own x402-paying fetch instead of a private key. */
10
+ paymentFetch?: typeof fetch;
11
+ }
12
+ export interface CreateOptions {
13
+ /** Lifetime in seconds, 300 to 604800 (7 days). Default 86400. */
14
+ ttl?: number;
15
+ /** Extra secret the recipient must also know. Share it through a different channel than the link. */
16
+ passphrase?: string;
17
+ /** Email the link to this address. The server sees the link (including key) for that one request. */
18
+ emailTo?: string;
19
+ /** Optional short note shown in the email, e.g. "DB password for staging". Never put the secret here. */
20
+ emailNote?: string;
21
+ }
22
+ export interface CreatedSecret {
23
+ id: string;
24
+ /** Full share link including the decryption key in the #fragment. Treat it like the secret itself. */
25
+ link: string;
26
+ expiresAt: string;
27
+ /** Lets the creator check status or burn the secret. Not needed by the recipient. */
28
+ receiptToken: string;
29
+ emailed: boolean;
30
+ /** On-chain settlement transaction hash, if reported. */
31
+ paymentTx?: string;
32
+ }
33
+ export type SecretStatus = "pending" | "read" | "burned" | "expired";
34
+ export declare class NcryptedError extends Error {
35
+ readonly status: number;
36
+ constructor(message: string, status: number);
37
+ }
38
+ export declare class Ncrypted {
39
+ readonly baseUrl: string;
40
+ private payFetch?;
41
+ constructor(opts?: NcryptedOptions);
42
+ /** Encrypts locally, pays 0.005 USDC via x402, stores the ciphertext and returns a one-time link. */
43
+ create(secret: string, opts?: CreateOptions): Promise<CreatedSecret>;
44
+ /** Sends the link by email once. Allowed only for the creator and only once per secret. */
45
+ email(id: string, receiptToken: string, link: string, to: string, note?: string): Promise<void>;
46
+ /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
47
+ read(link: string, passphrase?: string): Promise<string>;
48
+ /** Whether the secret has been read yet. Needs the creator's receipt token. */
49
+ status(id: string, receiptToken: string): Promise<{
50
+ status: SecretStatus;
51
+ readAt?: string;
52
+ expiresAt?: string;
53
+ }>;
54
+ /** Destroys an unread secret early. Needs the creator's receipt token. */
55
+ burn(id: string, receiptToken: string): Promise<void>;
56
+ }
package/dist/client.js ADDED
@@ -0,0 +1,133 @@
1
+ import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
2
+ import { ExactEvmScheme } from "@x402/evm";
3
+ import { privateKeyToAccount } from "viem/accounts";
4
+ import { encrypt, decrypt, parseLink } from "./crypto.js";
5
+ export const DEFAULT_BASE_URL = "https://ncrypted-ai.vercel.app";
6
+ /** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
7
+ export const MAX_PRICE = "$0.01";
8
+ export class NcryptedError extends Error {
9
+ status;
10
+ constructor(message, status) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ }
15
+ function settlementOf(res) {
16
+ const header = res.headers.get("payment-response") ?? res.headers.get("x-payment-response");
17
+ if (!header)
18
+ return undefined;
19
+ try {
20
+ return decodePaymentResponseHeader(header);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ async function errorFrom(res) {
27
+ let msg = res.statusText;
28
+ try {
29
+ const body = (await res.json());
30
+ if (body.error)
31
+ msg = body.error;
32
+ }
33
+ catch { }
34
+ if (res.status === 402) {
35
+ const reason = settlementOf(res)?.errorReason;
36
+ msg = reason
37
+ ? `Payment settlement failed (${reason}). You were not charged.`
38
+ : `Payment required (${msg}). Check the wallet holds USDC on Base.`;
39
+ }
40
+ return new NcryptedError(msg, res.status);
41
+ }
42
+ /** Settlement attempts per create. A failed settlement never moves funds, so retrying is safe. */
43
+ const SETTLE_ATTEMPTS = 3;
44
+ export class Ncrypted {
45
+ baseUrl;
46
+ payFetch;
47
+ constructor(opts = {}) {
48
+ const env = typeof process !== "undefined" ? process.env : {};
49
+ this.baseUrl = (opts.baseUrl ?? env.NCRYPTED_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
50
+ const pk = opts.privateKey ?? env.NCRYPTED_WALLET_PRIVATE_KEY;
51
+ if (opts.paymentFetch)
52
+ this.payFetch = opts.paymentFetch;
53
+ else if (pk) {
54
+ const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
55
+ this.payFetch = wrapFetchWithPaymentFromConfig(fetch, {
56
+ schemes: [{ network: "eip155:*", client: new ExactEvmScheme(account) }],
57
+ spendControls: { maxAmountPerPayment: MAX_PRICE },
58
+ });
59
+ }
60
+ }
61
+ /** Encrypts locally, pays 0.005 USDC via x402, stores the ciphertext and returns a one-time link. */
62
+ async create(secret, opts = {}) {
63
+ if (!this.payFetch) {
64
+ throw new Error("No wallet configured. Set NCRYPTED_WALLET_PRIVATE_KEY (a Base wallet with USDC) or pass privateKey.");
65
+ }
66
+ const { payload, key } = await encrypt(secret, opts.passphrase);
67
+ const body = { ...payload, ttl: opts.ttl ?? 86400 };
68
+ // The link is only knowable after the server assigns an id, so email goes in a second, free call.
69
+ let res;
70
+ for (let attempt = 1;; attempt++) {
71
+ res = await this.payFetch(`${this.baseUrl}/api/v1/secrets`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify(body),
75
+ });
76
+ // Retry only when the payment was signed but settlement failed on-chain (e.g. a facilitator RPC hiccup).
77
+ if (res.status === 402 && settlementOf(res)?.success === false && attempt < SETTLE_ATTEMPTS)
78
+ continue;
79
+ break;
80
+ }
81
+ if (!res.ok)
82
+ throw await errorFrom(res);
83
+ const data = (await res.json());
84
+ const link = `${data.url}#${key}`;
85
+ const paymentTx = settlementOf(res)?.transaction;
86
+ let emailed = false;
87
+ if (opts.emailTo) {
88
+ await this.email(data.id, data.receiptToken, link, opts.emailTo, opts.emailNote);
89
+ emailed = true;
90
+ }
91
+ return { id: data.id, link, expiresAt: data.expiresAt, receiptToken: data.receiptToken, emailed, paymentTx };
92
+ }
93
+ /** Sends the link by email once. Allowed only for the creator and only once per secret. */
94
+ async email(id, receiptToken, link, to, note) {
95
+ const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/email`, {
96
+ method: "POST",
97
+ headers: { "content-type": "application/json", authorization: `Bearer ${receiptToken}` },
98
+ body: JSON.stringify({ to, link, note }),
99
+ });
100
+ if (!res.ok)
101
+ throw await errorFrom(res);
102
+ }
103
+ /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
104
+ async read(link, passphrase) {
105
+ const { origin, id, key } = parseLink(link);
106
+ const res = await fetch(`${origin}/api/v1/secrets/${id}/reveal`, { method: "POST" });
107
+ if (res.status === 410 || res.status === 404) {
108
+ throw new NcryptedError("This secret has already been read, burned, or has expired.", 410);
109
+ }
110
+ if (!res.ok)
111
+ throw await errorFrom(res);
112
+ const payload = (await res.json());
113
+ return decrypt(payload, key, passphrase);
114
+ }
115
+ /** Whether the secret has been read yet. Needs the creator's receipt token. */
116
+ async status(id, receiptToken) {
117
+ const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/status`, {
118
+ headers: { authorization: `Bearer ${receiptToken}` },
119
+ });
120
+ if (!res.ok)
121
+ throw await errorFrom(res);
122
+ return (await res.json());
123
+ }
124
+ /** Destroys an unread secret early. Needs the creator's receipt token. */
125
+ async burn(id, receiptToken) {
126
+ const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}`, {
127
+ method: "DELETE",
128
+ headers: { authorization: `Bearer ${receiptToken}` },
129
+ });
130
+ if (!res.ok)
131
+ throw await errorFrom(res);
132
+ }
133
+ }
@@ -0,0 +1,25 @@
1
+ export declare const VERSION = 1;
2
+ export declare const MAX_PLAINTEXT_BYTES: number;
3
+ export interface EncryptedPayload {
4
+ v: number;
5
+ ciphertext: string;
6
+ iv: string;
7
+ salt: string;
8
+ hasPassphrase: boolean;
9
+ }
10
+ export declare function toB64url(bytes: Uint8Array): string;
11
+ export declare function fromB64url(s: string): Uint8Array<ArrayBuffer>;
12
+ export declare function randomBytes(n: number): Uint8Array<ArrayBuffer>;
13
+ /** Encrypts plaintext with a fresh random link key. Returns the payload for the server and the key for the URL fragment. */
14
+ export declare function encrypt(plaintext: string, passphrase?: string): Promise<{
15
+ payload: EncryptedPayload;
16
+ key: string;
17
+ }>;
18
+ /** Decrypts a payload with the link key (and passphrase if one was set). Throws on a wrong key, passphrase or tampered data. */
19
+ export declare function decrypt(payload: EncryptedPayload, linkKey: string, passphrase?: string): Promise<string>;
20
+ /** Splits a share link into its id and key: https://host/s/{id}#{key} */
21
+ export declare function parseLink(link: string): {
22
+ origin: string;
23
+ id: string;
24
+ key: string;
25
+ };
package/dist/crypto.js ADDED
@@ -0,0 +1,74 @@
1
+ // Isomorphic (Node 20+ / browser) zero-knowledge encryption for ncrypted.
2
+ // The 256-bit link key never leaves the client; the server only sees ciphertext.
3
+ export const VERSION = 1;
4
+ export const MAX_PLAINTEXT_BYTES = 64 * 1024;
5
+ const PBKDF2_ITERATIONS = 600_000;
6
+ const INFO = new TextEncoder().encode("ncrypted v1");
7
+ export function toB64url(bytes) {
8
+ let bin = "";
9
+ for (const b of bytes)
10
+ bin += String.fromCharCode(b);
11
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
12
+ }
13
+ export function fromB64url(s) {
14
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
15
+ const bin = atob(b64);
16
+ const out = new Uint8Array(bin.length);
17
+ for (let i = 0; i < bin.length; i++)
18
+ out[i] = bin.charCodeAt(i);
19
+ return out;
20
+ }
21
+ export function randomBytes(n) {
22
+ return crypto.getRandomValues(new Uint8Array(n));
23
+ }
24
+ async function deriveKey(linkKey, salt, passphrase) {
25
+ let ikm = new Uint8Array(linkKey);
26
+ if (passphrase) {
27
+ const pw = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveBits"]);
28
+ const stretched = new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS }, pw, 256));
29
+ ikm = new Uint8Array(linkKey.length + stretched.length);
30
+ ikm.set(linkKey);
31
+ ikm.set(stretched, linkKey.length);
32
+ }
33
+ const base = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
34
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: INFO }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
35
+ }
36
+ /** Encrypts plaintext with a fresh random link key. Returns the payload for the server and the key for the URL fragment. */
37
+ export async function encrypt(plaintext, passphrase) {
38
+ const data = new TextEncoder().encode(plaintext);
39
+ if (data.length === 0)
40
+ throw new Error("Secret is empty");
41
+ if (data.length > MAX_PLAINTEXT_BYTES)
42
+ throw new Error(`Secret exceeds ${MAX_PLAINTEXT_BYTES} bytes`);
43
+ const linkKey = randomBytes(32);
44
+ const salt = randomBytes(16);
45
+ const iv = randomBytes(12);
46
+ const key = await deriveKey(linkKey, salt, passphrase);
47
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
48
+ return {
49
+ payload: { v: VERSION, ciphertext: toB64url(ct), iv: toB64url(iv), salt: toB64url(salt), hasPassphrase: !!passphrase },
50
+ key: toB64url(linkKey),
51
+ };
52
+ }
53
+ /** Decrypts a payload with the link key (and passphrase if one was set). Throws on a wrong key, passphrase or tampered data. */
54
+ export async function decrypt(payload, linkKey, passphrase) {
55
+ if (payload.hasPassphrase && !passphrase)
56
+ throw new Error("This secret requires a passphrase");
57
+ const key = await deriveKey(fromB64url(linkKey), fromB64url(payload.salt), payload.hasPassphrase ? passphrase : undefined);
58
+ try {
59
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64url(payload.iv) }, key, fromB64url(payload.ciphertext));
60
+ return new TextDecoder().decode(pt);
61
+ }
62
+ catch {
63
+ throw new Error("Decryption failed: wrong key or passphrase, or the data was tampered with");
64
+ }
65
+ }
66
+ /** Splits a share link into its id and key: https://host/s/{id}#{key} */
67
+ export function parseLink(link) {
68
+ const url = new URL(link);
69
+ const m = url.pathname.match(/^\/s\/([A-Za-z0-9_-]{16,64})\/?$/);
70
+ const key = url.hash.slice(1);
71
+ if (!m || !key)
72
+ throw new Error("Not a valid ncrypted link (expected https://host/s/{id}#{key})");
73
+ return { origin: url.origin, id: m[1], key };
74
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./client.js";
2
+ export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES, type EncryptedPayload } from "./crypto.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./client.js";
2
+ export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES } from "./crypto.js";
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "ncrypted",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./crypto": {
15
+ "types": "./dist/crypto.d.ts",
16
+ "default": "./dist/crypto.js"
17
+ }
18
+ },
19
+ "bin": {
20
+ "ncrypted": "dist/cli.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "keywords": [
31
+ "secrets",
32
+ "one-time",
33
+ "x402",
34
+ "agents",
35
+ "mcp",
36
+ "encryption",
37
+ "usdc"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "test": "vitest run"
42
+ },
43
+ "dependencies": {
44
+ "@x402/evm": "^2.28.0",
45
+ "@x402/fetch": "^2.28.0",
46
+ "viem": "^2.57.7"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.0.0",
50
+ "typescript": "^5.9.0",
51
+ "vitest": "^5.0.5"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/Seidl-Family/ncrypted.git",
56
+ "directory": "packages/sdk"
57
+ },
58
+ "homepage": "https://github.com/Seidl-Family/ncrypted",
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }