@venn-lang/crypto 0.1.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.
@@ -0,0 +1,77 @@
1
+ import { type ActionContext, type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { equals, fromHex, toBase64Url, toBytes } from "../bytes/index.js";
4
+ import { decodeJwt } from "../jwt/index.js";
5
+ import { CryptoEnginePort, type HashAlgorithm } from "../port/index.js";
6
+
7
+ const ALGORITHMS: Record<string, HashAlgorithm> = {
8
+ HS256: "sha256",
9
+ HS384: "sha384",
10
+ HS512: "sha512",
11
+ };
12
+
13
+ const signParams = z.object({
14
+ payload: z.record(z.string(), z.unknown()),
15
+ secret: z.string(),
16
+ algorithm: z.enum(["HS256", "HS384", "HS512"]).default("HS256"),
17
+ });
18
+ const verifyParams = z.object({ secret: z.string() });
19
+
20
+ /** The `crypto.jwt.*` verbs: read a token, mint one, check one. */
21
+ export const jwtActions: ActionDefinition[] = [
22
+ defineAction({
23
+ name: "jwt.decode",
24
+ doc: "Take a token apart without verifying it: header, payload, signature.",
25
+ args: [arg("token", t.string, "The token to take apart. Nothing is verified.")],
26
+ result: t.ref("crypto.Jwt"),
27
+ run: (_ctx, input) => decodeJwt(String(input.args[0] ?? "")),
28
+ }),
29
+ defineAction({
30
+ name: "jwt.sign",
31
+ doc: "Mint an HMAC-signed token.",
32
+ params: signParams,
33
+ // Payload, secret and algorithm are all options, so nothing goes positionally.
34
+ result: t.string,
35
+ run: (ctx, input) => sign(ctx, input.params as z.infer<typeof signParams>),
36
+ }),
37
+ defineAction({
38
+ name: "jwt.verify",
39
+ doc: "True when the token's signature matches the secret.",
40
+ params: verifyParams,
41
+ args: [arg("token", t.string, "The token to check against the secret in the options.")],
42
+ result: t.bool,
43
+ run: (ctx, input) =>
44
+ verify(ctx, String(input.args[0] ?? ""), input.params as { secret: string }),
45
+ }),
46
+ ];
47
+
48
+ async function sign(ctx: ActionContext, params: z.infer<typeof signParams>): Promise<string> {
49
+ const header = { alg: params.algorithm, typ: "JWT" };
50
+ const input = `${encode(header)}.${encode(params.payload)}`;
51
+ const mac = await ctx.port(CryptoEnginePort).hmac({
52
+ algorithm: ALGORITHMS[params.algorithm] as HashAlgorithm,
53
+ key: params.secret,
54
+ data: input,
55
+ });
56
+ return `${input}.${toBase64Url(fromHex(mac))}`;
57
+ }
58
+
59
+ async function verify(
60
+ ctx: ActionContext,
61
+ token: string,
62
+ params: { secret: string },
63
+ ): Promise<boolean> {
64
+ const decoded = decodeJwt(token);
65
+ const algorithm = ALGORITHMS[String(decoded.header.alg ?? "")];
66
+ if (!algorithm) return false;
67
+ const mac = await ctx.port(CryptoEnginePort).hmac({
68
+ algorithm,
69
+ key: params.secret,
70
+ data: decoded.signingInput,
71
+ });
72
+ return equals(toBase64Url(fromHex(mac)), decoded.signature);
73
+ }
74
+
75
+ function encode(value: unknown): string {
76
+ return toBase64Url(toBytes(JSON.stringify(value)));
77
+ }
@@ -0,0 +1,66 @@
1
+ import { type ActionContext, type ActionDefinition, arg, defineAction, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { equals } from "../bytes/index.js";
4
+ import { CryptoEnginePort, type HashAlgorithm } from "../port/index.js";
5
+
6
+ const SCHEME = "pbkdf2";
7
+ const DEFAULT_ITERATIONS = 100_000;
8
+
9
+ const hashParams = z
10
+ .object({
11
+ iterations: z.number().int().positive().default(DEFAULT_ITERATIONS),
12
+ algorithm: z.enum(["sha256", "sha512"]).default("sha256"),
13
+ })
14
+ .optional();
15
+ const verifyParams = z.object({ hash: z.string() });
16
+
17
+ /**
18
+ * The `crypto.password.hash` and `crypto.password.verify` verbs.
19
+ *
20
+ * PBKDF2 rather than bcrypt, because WebCrypto offers PBKDF2 and so the package
21
+ * needs no native module. The encoded form carries its own cost parameters,
22
+ * `pbkdf2$sha256$iterations$salt$derived`, so a stored hash stays verifiable
23
+ * after the defaults here change.
24
+ */
25
+ export const passwordActions: ActionDefinition[] = [
26
+ defineAction({
27
+ name: "password.hash",
28
+ doc: "Hash a password with PBKDF2, returning a self-describing string.",
29
+ params: hashParams,
30
+ args: [arg("password", t.string, "The password in the clear.")],
31
+ result: t.string,
32
+ run: (ctx, input) => hash(ctx, String(input.args[0] ?? ""), input.params),
33
+ }),
34
+ defineAction({
35
+ name: "password.verify",
36
+ doc: "True when the password matches a hash produced by `password.hash`.",
37
+ params: verifyParams,
38
+ args: [arg("password", t.string, "The password in the clear, to check against the hash.")],
39
+ result: t.bool,
40
+ run: (ctx, input) =>
41
+ verify(ctx, String(input.args[0] ?? ""), (input.params as { hash: string }).hash),
42
+ }),
43
+ ];
44
+
45
+ async function hash(ctx: ActionContext, password: string, params: unknown): Promise<string> {
46
+ const options = (params ?? {}) as { iterations?: number; algorithm?: HashAlgorithm };
47
+ const iterations = options.iterations ?? DEFAULT_ITERATIONS;
48
+ const algorithm = options.algorithm ?? "sha256";
49
+ const salt = ctx.port(CryptoEnginePort).randomBytes(16);
50
+ const derived = await ctx
51
+ .port(CryptoEnginePort)
52
+ .derive({ password, salt, iterations, algorithm });
53
+ return [SCHEME, algorithm, iterations, salt, derived].join("$");
54
+ }
55
+
56
+ async function verify(ctx: ActionContext, password: string, encoded: string): Promise<boolean> {
57
+ const [scheme, algorithm, iterations, salt, derived] = encoded.split("$");
58
+ if (scheme !== SCHEME || !algorithm || !salt || !derived) return false;
59
+ const again = await ctx.port(CryptoEnginePort).derive({
60
+ password,
61
+ salt,
62
+ iterations: Number(iterations),
63
+ algorithm: algorithm as HashAlgorithm,
64
+ });
65
+ return equals(again, derived);
66
+ }
@@ -0,0 +1,70 @@
1
+ // Conversions between the shapes crypto work needs: text, bytes, hex, base64.
2
+ // All pure, all platform-neutral.
3
+
4
+ /** Bytes backed by a plain `ArrayBuffer`, the only shape WebCrypto accepts. */
5
+ export type Bytes = Uint8Array<ArrayBuffer>;
6
+
7
+ /** UTF-8 encode a string. */
8
+ export function toBytes(text: string): Bytes {
9
+ return new TextEncoder().encode(text) as Bytes;
10
+ }
11
+
12
+ /** UTF-8 decode bytes back to a string. Invalid sequences become the replacement character. */
13
+ export function fromBytes(bytes: Uint8Array): string {
14
+ return new TextDecoder().decode(bytes);
15
+ }
16
+
17
+ /** Lowercase hex, two characters per byte. */
18
+ export function toHex(bytes: Uint8Array): string {
19
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
20
+ }
21
+
22
+ /** Read a hex string back into bytes. A trailing odd character is dropped. */
23
+ export function fromHex(hex: string): Bytes {
24
+ const pairs = hex.match(/../g) ?? [];
25
+ return Uint8Array.from(pairs.map((pair) => Number.parseInt(pair, 16))) as Bytes;
26
+ }
27
+
28
+ /** Standard base64, padded. */
29
+ export function toBase64(bytes: Uint8Array): string {
30
+ return btoa(String.fromCharCode(...bytes));
31
+ }
32
+
33
+ /**
34
+ * Read standard base64 back into bytes.
35
+ *
36
+ * @throws DOMException when the text is not valid base64.
37
+ */
38
+ export function fromBase64(text: string): Bytes {
39
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0)) as Bytes;
40
+ }
41
+
42
+ /** Base64url, the flavour JWT uses: `+/` become `-_` and padding is dropped. */
43
+ export function toBase64Url(bytes: Uint8Array): string {
44
+ return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+
47
+ /**
48
+ * Read base64url back into bytes, restoring the padding first.
49
+ *
50
+ * @throws DOMException when the text is not valid base64url.
51
+ */
52
+ export function fromBase64Url(text: string): Bytes {
53
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
54
+ return fromBase64(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
55
+ }
56
+
57
+ /**
58
+ * Compare two strings in time that does not depend on where they differ.
59
+ *
60
+ * Use this for every signature and digest check: `===` returns early on the
61
+ * first mismatched byte, which leaks the correct prefix to a patient attacker.
62
+ */
63
+ export function equals(left: string, right: string): boolean {
64
+ if (left.length !== right.length) return false;
65
+ let diff = 0;
66
+ for (let index = 0; index < left.length; index++) {
67
+ diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
68
+ }
69
+ return diff === 0;
70
+ }
@@ -0,0 +1,12 @@
1
+ export type { Bytes } from "./bytes.js";
2
+ export {
3
+ equals,
4
+ fromBase64,
5
+ fromBase64Url,
6
+ fromBytes,
7
+ fromHex,
8
+ toBase64,
9
+ toBase64Url,
10
+ toBytes,
11
+ toHex,
12
+ } from "./bytes.js";
@@ -0,0 +1,43 @@
1
+ import type { CryptoEngine } from "../port/index.js";
2
+
3
+ /**
4
+ * A deterministic stand-in for tests: same input, same output, no platform crypto.
5
+ *
6
+ * Not secure and not meant to be. It exists so assertions over a digest, an HMAC
7
+ * or a random token stay reproducible from run to run.
8
+ */
9
+ export function createFakeCryptoEngine(): CryptoEngine {
10
+ let counter = 0;
11
+ return {
12
+ digest: ({ algorithm, data }) => Promise.resolve(stable(`${algorithm}|${data}`)),
13
+ hmac: ({ algorithm, key, data }) => Promise.resolve(stable(`${algorithm}|${key}|${data}`)),
14
+ derive: (args) =>
15
+ Promise.resolve(stable(`${args.algorithm}|${args.password}|${args.salt}|${args.iterations}`)),
16
+ randomBytes: (size) => {
17
+ counter += 1;
18
+ return sized(stable(`bytes|${counter}`), size);
19
+ },
20
+ };
21
+ }
22
+
23
+ // FNV-1a, run over the seed with a changing suffix until 64 hex digits are filled.
24
+ function stable(seed: string): string {
25
+ let out = "";
26
+ for (let round = 0; out.length < 64; round++) {
27
+ out += fnv1a(`${seed}|${round}`).toString(16).padStart(8, "0");
28
+ }
29
+ return out.slice(0, 64);
30
+ }
31
+
32
+ function fnv1a(text: string): number {
33
+ let hash = 0x811c9dc5;
34
+ for (let index = 0; index < text.length; index++) {
35
+ hash ^= text.charCodeAt(index);
36
+ hash = Math.imul(hash, 0x01000193) >>> 0;
37
+ }
38
+ return hash >>> 0;
39
+ }
40
+
41
+ function sized(hex: string, size: number): string {
42
+ return hex.repeat(Math.ceil((size * 2) / hex.length)).slice(0, size * 2);
43
+ }
@@ -0,0 +1,2 @@
1
+ export { createFakeCryptoEngine } from "./fake-engine.js";
2
+ export { createWebCryptoEngine } from "./web-crypto-engine.js";
@@ -0,0 +1,45 @@
1
+ import { toBytes, toHex } from "../bytes/index.js";
2
+ import type { CryptoEngine, DeriveArgs, HashAlgorithm } from "../port/index.js";
3
+
4
+ const NAMES: Record<HashAlgorithm, string> = {
5
+ sha1: "SHA-1",
6
+ sha256: "SHA-256",
7
+ sha384: "SHA-384",
8
+ sha512: "SHA-512",
9
+ };
10
+
11
+ /**
12
+ * The real engine, backed by the platform's global WebCrypto.
13
+ *
14
+ * WebCrypto exists in Node 24 and in browsers alike, so nothing here imports
15
+ * `node:crypto` and the package stays platform-neutral.
16
+ */
17
+ export function createWebCryptoEngine(): CryptoEngine {
18
+ return {
19
+ digest: async ({ algorithm, data }) =>
20
+ toHex(new Uint8Array(await crypto.subtle.digest(NAMES[algorithm], toBytes(data)))),
21
+ hmac: async ({ algorithm, key, data }) =>
22
+ toHex(new Uint8Array(await sign(algorithm, key, data))),
23
+ derive: async (args) => toHex(new Uint8Array(await deriveBits(args))),
24
+ randomBytes: (size) => toHex(crypto.getRandomValues(new Uint8Array(size))),
25
+ };
26
+ }
27
+
28
+ async function sign(algorithm: HashAlgorithm, key: string, data: string): Promise<ArrayBuffer> {
29
+ const spec = { name: "HMAC", hash: NAMES[algorithm] };
30
+ const material = await crypto.subtle.importKey("raw", toBytes(key), spec, false, ["sign"]);
31
+ return crypto.subtle.sign("HMAC", material, toBytes(data));
32
+ }
33
+
34
+ async function deriveBits(args: DeriveArgs): Promise<ArrayBuffer> {
35
+ const material = await crypto.subtle.importKey("raw", toBytes(args.password), "PBKDF2", false, [
36
+ "deriveBits",
37
+ ]);
38
+ const params = {
39
+ name: "PBKDF2",
40
+ salt: toBytes(args.salt),
41
+ iterations: args.iterations,
42
+ hash: NAMES[args.algorithm],
43
+ };
44
+ return crypto.subtle.deriveBits(params, material, 256);
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // @venn-lang/crypto: hashes, HMACs, base64, PBKDF2 password hashing and JSON Web
2
+ // Tokens, all reached through the CryptoEngine port.
3
+
4
+ export {
5
+ equals,
6
+ fromBase64,
7
+ fromBase64Url,
8
+ fromBytes,
9
+ fromHex,
10
+ toBase64,
11
+ toBase64Url,
12
+ toBytes,
13
+ toHex,
14
+ } from "./bytes/index.js";
15
+ export { createFakeCryptoEngine, createWebCryptoEngine } from "./engines/index.js";
16
+ export type { DecodedJwt } from "./jwt/index.js";
17
+ export { decodeJwt } from "./jwt/index.js";
18
+ export { cryptoPlugin } from "./plugin.js";
19
+ export type { CryptoEngine, DeriveArgs, HashAlgorithm } from "./port/index.js";
20
+ export { CryptoEnginePort } from "./port/index.js";
@@ -0,0 +1,37 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import { fromBase64Url, fromBytes } from "../bytes/index.js";
3
+ import type { DecodedJwt } from "./jwt.types.js";
4
+
5
+ /**
6
+ * Split a token and decode its header and payload, without verifying anything.
7
+ *
8
+ * Reading a token's claims and trusting them are separate acts. This is the
9
+ * first; `crypto.jwt.verify` is the second.
10
+ *
11
+ * @param token A compact JWS, `header.payload.signature`.
12
+ * @returns The decoded sections plus the `signingInput` a signature covers.
13
+ * @throws VennError `VN7003` when the token has fewer than two sections, or when
14
+ * a section is not base64url-encoded JSON.
15
+ */
16
+ export function decodeJwt(token: string): DecodedJwt {
17
+ const parts = token.split(".");
18
+ if (parts.length < 2) throw malformed("expected header.payload.signature");
19
+ return {
20
+ header: section(parts[0], "header"),
21
+ payload: section(parts[1], "payload"),
22
+ signature: parts[2] ?? "",
23
+ signingInput: `${parts[0]}.${parts[1]}`,
24
+ };
25
+ }
26
+
27
+ function section(part: string | undefined, what: string): Record<string, unknown> {
28
+ try {
29
+ return JSON.parse(fromBytes(fromBase64Url(part ?? "")));
30
+ } catch {
31
+ throw malformed(`its ${what} is not base64url-encoded JSON`);
32
+ }
33
+ }
34
+
35
+ function malformed(detail: string): VennError {
36
+ return new VennError({ code: "VN7003", message: `Not a JWT — ${detail}.` });
37
+ }
@@ -0,0 +1,2 @@
1
+ export { decodeJwt } from "./decode.js";
2
+ export type { DecodedJwt } from "./jwt.types.js";
@@ -0,0 +1,9 @@
1
+ /** A JWT taken apart, verified or not. */
2
+ export interface DecodedJwt {
3
+ header: Record<string, unknown>;
4
+ payload: Record<string, unknown>;
5
+ /** The third section, still base64url. Empty when the token carries none. */
6
+ signature: string;
7
+ /** `header.payload`, exactly the bytes the signature covers. */
8
+ signingInput: string;
9
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
2
+ import { cryptoActions } from "./actions/index.js";
3
+ import { cryptoTypeDefs } from "./types.js";
4
+
5
+ /**
6
+ * The `crypto` plugin: digests, encodings, password hashing and JSON Web Tokens.
7
+ *
8
+ * Every primitive comes from `CryptoEnginePort`, so a host can swap WebCrypto for
9
+ * the deterministic fake and keep assertions reproducible. No capability is
10
+ * required: WebCrypto is present in Node and in the browser alike.
11
+ */
12
+ export const cryptoPlugin: PluginDefinition = definePlugin({
13
+ name: "venn/crypto",
14
+ version: "0.1.0",
15
+ namespace: "crypto",
16
+ actions: cryptoActions,
17
+ typeDefs: cryptoTypeDefs,
18
+ });
@@ -0,0 +1,15 @@
1
+ import type { Port } from "@venn-lang/contracts";
2
+ import type { CryptoEngine } from "./crypto-engine.types.js";
3
+
4
+ /**
5
+ * The port every `crypto` verb reaches its primitives through.
6
+ *
7
+ * Bound by the host to `createWebCryptoEngine` or `createFakeCryptoEngine`.
8
+ * Requires no capability, because WebCrypto is present on every target.
9
+ */
10
+ export const CryptoEnginePort: Port<CryptoEngine> = {
11
+ id: "venn.port.crypto-engine",
12
+ version: 1,
13
+ requires: [],
14
+ methods: ["digest", "hmac", "derive", "randomBytes"],
15
+ };
@@ -0,0 +1,23 @@
1
+ /** The digests every engine supports. */
2
+ export type HashAlgorithm = "sha1" | "sha256" | "sha384" | "sha512";
3
+
4
+ /** Arguments for {@link CryptoEngine.derive}: a password plus its PBKDF2 cost parameters. */
5
+ export interface DeriveArgs {
6
+ password: string;
7
+ salt: string;
8
+ iterations: number;
9
+ algorithm: HashAlgorithm;
10
+ }
11
+
12
+ /**
13
+ * The primitives every `crypto` verb is built from.
14
+ *
15
+ * Each method answers in lowercase hex. That single shape is what the actions
16
+ * convert from, so an engine that returned bytes would break every caller.
17
+ */
18
+ export interface CryptoEngine {
19
+ digest(args: { algorithm: HashAlgorithm; data: string }): Promise<string>;
20
+ hmac(args: { algorithm: HashAlgorithm; key: string; data: string }): Promise<string>;
21
+ derive(args: DeriveArgs): Promise<string>;
22
+ randomBytes(size: number): string;
23
+ }
@@ -0,0 +1,2 @@
1
+ export { CryptoEnginePort } from "./crypto-engine.port.js";
2
+ export type { CryptoEngine, DeriveArgs, HashAlgorithm } from "./crypto-engine.types.js";
package/src/types.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { type TypeSpec, t } from "@venn-lang/types";
2
+
3
+ /**
4
+ * The named types `@venn-lang/crypto` publishes to scripts, keyed by their bare name.
5
+ *
6
+ * One name is enough: every other verb answers with a string or a boolean, which
7
+ * reads better inline than behind a name that adds nothing. Hand-mirrored from
8
+ * `DecodedJwt` in `jwt/jwt.types.ts`, so change the two together.
9
+ */
10
+ export const cryptoTypeDefs: Readonly<Record<string, TypeSpec>> = {
11
+ /**
12
+ * A token taken apart. The claims are whatever the issuer put there, so both
13
+ * sections are maps of dynamic rather than a shape nobody can promise.
14
+ */
15
+ Jwt: t.record({
16
+ header: t.map(t.dynamic),
17
+ payload: t.map(t.dynamic),
18
+ signature: t.string,
19
+ signingInput: t.string,
20
+ }),
21
+ };