@stacksjs/security 0.70.88 → 0.70.90

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,13 @@
1
+ import { encrypt as cryptoEncrypt } from 'ts-security-crypto';
2
+ declare function encrypt(message: string, customPassphrase?: string): Promise<string>;
3
+ declare function decrypt(encrypted: string, customPassphrase?: string): Promise<string>;
4
+ /**
5
+ * For tests + observability: reset the per-process derived-key cache.
6
+ * Production code never needs to call this — the cache is bounded
7
+ * (`DERIVED_KEY_CACHE_MAX = 64`) and entries evict in FIFO order.
8
+ */
9
+ export declare function _resetCryptCacheForTests(): void;
10
+ // Re-export pre-existing helpers from ts-security-crypto so callers
11
+ // that imported them from `@stacksjs/security` keep working.
12
+ export { decrypt, encrypt };
13
+ export { cryptoEncrypt as legacyEncrypt };
package/dist/hash.d.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { base64Encode } from 'ts-security-crypto';
2
+ /**
3
+ * Detect the hashing algorithm from a hash string
4
+ * Laravel-compatible hash detection
5
+ */
6
+ export declare function detectAlgorithm(hash: string): HashAlgorithm | 'unknown';
7
+ /**
8
+ * Extract information from a hash string
9
+ * Similar to Laravel's Hash::info()
10
+ */
11
+ export declare function info(hash: string): HashInfo;
12
+ /**
13
+ * Check if a hash needs to be rehashed
14
+ * Similar to Laravel's Hash::needsRehash()
15
+ *
16
+ * Returns true if:
17
+ * - The algorithm doesn't match the configured default
18
+ * - The cost/rounds don't match the configured values
19
+ */
20
+ export declare function needsRehash(hash: string, options?: HashMakeOptions): boolean;
21
+ /**
22
+ * Hash a value using the configured or specified algorithm
23
+ * Similar to Laravel's Hash::make()
24
+ */
25
+ export declare function make(value: string, options?: HashMakeOptions): Promise<string>;
26
+ /**
27
+ * Verify a value against a hash
28
+ * Similar to Laravel's Hash::check()
29
+ *
30
+ * Auto-detects the algorithm from the hash format
31
+ */
32
+ export declare function check(value: string, hash: string): Promise<boolean>;
33
+ /**
34
+ * Encode a password using bcrypt
35
+ * Laravel uses $2y$ prefix, rounds 10-12 by default
36
+ */
37
+ export declare function bcryptEncode(password: string, rounds?: number): Promise<string>;
38
+ /**
39
+ * Encode a password using Argon2
40
+ */
41
+ export declare function argon2Encode(password: string, options?: {
42
+ type?: 'argon2id' | 'argon2i' | 'argon2d'
43
+ memory?: number
44
+ time?: number
45
+ }): Promise<string>;
46
+ /**
47
+ * Verify a password against an Argon2 hash.
48
+ *
49
+ * Refuses to verify hashes that aren't actually Argon2 — `Bun.password.verify`
50
+ * auto-detects the algorithm from the hash prefix, which means a stray
51
+ * call `argon2Verify(password, bcryptHash)` would return `true` if the
52
+ * password matched. The algorithm guard restores the function-name
53
+ * contract (stacksjs/stacks#1861 H-8).
54
+ *
55
+ * @deprecated Use check() instead which auto-detects the algorithm
56
+ */
57
+ export declare function argon2Verify(password: string, hash: string): Promise<boolean>;
58
+ /**
59
+ * Verify a password against a bcrypt hash.
60
+ *
61
+ * Refuses to verify hashes that aren't actually bcrypt — see the note
62
+ * on {@link argon2Verify} (stacksjs/stacks#1861 H-8).
63
+ *
64
+ * @deprecated Use check() instead which auto-detects the algorithm
65
+ */
66
+ export declare function bcryptVerify(password: string, hash: string): Promise<boolean>;
67
+ /**
68
+ * Verify a password against a base64 encoded string
69
+ * Note: base64 is NOT a secure password hash - only for legacy support
70
+ */
71
+ export declare function base64Verify(password: string, hash: string): boolean;
72
+ /**
73
+ * Create an MD5 hash (NOT secure for passwords)
74
+ */
75
+ export declare function md5Encode(password: string): string;
76
+ /**
77
+ * Constant-time string comparison.
78
+ *
79
+ * Use this to compare opaque tokens (CSRF tokens, signed URL signatures,
80
+ * webhook HMACs, API keys). A naive `a === b` compares character-by-
81
+ * character and short-circuits on the first mismatch, so the time the
82
+ * comparison takes leaks information about how many leading characters
83
+ * matched — enough for a remote attacker to brute-force the rest.
84
+ *
85
+ * Strings of different lengths are reported as unequal in constant time
86
+ * so length itself doesn't leak.
87
+ */
88
+ export declare function timingSafeEqualString(a: string, b: string): boolean;
89
+ /**
90
+ * Options for creating a hash
91
+ */
92
+ export declare interface HashMakeOptions {
93
+ algorithm?: HashAlgorithm
94
+ rounds?: number
95
+ memory?: number
96
+ time?: number
97
+ }
98
+ /**
99
+ * Information about a hashed value
100
+ */
101
+ export declare interface HashInfo {
102
+ algorithm: HashAlgorithm | 'unknown'
103
+ options: {
104
+ rounds?: number
105
+ memory?: number
106
+ parallelism?: number
107
+ version?: number
108
+ }
109
+ }
110
+ /**
111
+ * Supported hashing algorithms
112
+ */
113
+ export type HashAlgorithm = 'bcrypt' | 'argon2' | 'argon2id' | 'argon2i' | 'argon2d';
114
+ // Function-based exports (preferred API)
115
+ export {
116
+ make as hashMake,
117
+ check as hashCheck,
118
+ needsRehash as hashNeedsRehash,
119
+ info as hashInfo,
120
+ detectAlgorithm as hashDetectAlgorithm,
121
+ };
122
+ // Legacy exports for backwards compatibility
123
+ export { make as makeHash, check as verifyHash, base64Encode };
@@ -0,0 +1,4 @@
1
+ export * from './crypt';
2
+ export * from './hash';
3
+ export * from './key';
4
+ export * from './webhook';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ var f=import.meta.require;import{Buffer as M}from"buffer";import{config as H}from"@stacksjs/config";import{decrypt as B,encrypt as JJ}from"ts-security-crypto";function W(J){return J.startsWith("base64:")?J.slice(7):J}var C=1,_=600000,S=32,O=16,R=12,v=64,z=new Map;function b(J,U,Q){return`${Q}:${M.from(U).toString("base64")}:${J}`}async function E(J,U,Q){let Z=b(J,U,Q),$=z.get(Z);if($)return $.hits++,z.delete(Z),z.set(Z,$),$.key;let G=await crypto.subtle.importKey("raw",new TextEncoder().encode(J),{name:"PBKDF2"},!1,["deriveKey"]),X=await crypto.subtle.deriveKey({name:"PBKDF2",salt:new Uint8Array(U),iterations:Q,hash:"SHA-256"},G,{name:"AES-GCM",length:S*8},!1,["encrypt","decrypt"]);if(z.set(Z,{key:X,hits:1}),z.size>v){let F=z.keys().next().value;if(F!==void 0)z.delete(F)}return X}async function y(J,U){let Q=crypto.getRandomValues(new Uint8Array(O)),Z=crypto.getRandomValues(new Uint8Array(R)),$=await E(U,Q,_),G=await crypto.subtle.encrypt({name:"AES-GCM",iv:Z},$,new TextEncoder().encode(J)),X=new Uint8Array(G),F=new Uint8Array(1+O+R+X.byteLength);return F[0]=C,F.set(Q,1),F.set(Z,1+O),F.set(X,1+O+R),M.from(F).toString("base64")}async function m(J,U){let Q=M.from(J,"base64");if(Q.length<1+O+R+16)return null;if(Q[0]!==C)return null;let Z=new Uint8Array(Q.subarray(1,1+O)),$=new Uint8Array(Q.subarray(1+O,1+O+R)),G=new Uint8Array(Q.subarray(1+O+R)),X=await E(U,Z,_);try{let F=await crypto.subtle.decrypt({name:"AES-GCM",iv:$},X,G);return new TextDecoder().decode(F)}catch{return null}}async function QJ(J,U){if(!J&&J!=="")throw Error("encrypt() requires a string message");let Q=U||H.app.key;if(!Q)throw Error("APP_KEY is not defined");return await y(J,W(Q))}async function UJ(J,U){if(!J)throw Error("decrypt() requires a non-empty encrypted string");let Q=U||H.app.key;if(!Q)throw Error("APP_KEY is not defined");let Z=W(Q),$=await m(J,Z);if($!==null)return $;try{return await B(J,Z)}catch(G){if(Z!==Q)try{return await B(J,Q)}catch{}throw G}}function ZJ(){z.clear()}import{timingSafeEqual as K}from"crypto";import{base64Decode as u,base64Encode as FJ,hashPassword as T,md5 as l,verifyPassword as L}from"ts-security-crypto";var D=null;function q(){if(D)return D;try{let{hashing:J}=f("@stacksjs/config");return D=J||{},D}catch{return console.warn("[Security] Failed to load hashing config, using defaults"),D={driver:"bcrypt",bcrypt:{rounds:12},argon2:{memory:65536,time:2}},D}}function V(J){if(!J||typeof J!=="string")return"unknown";if(J.startsWith("$2a$")||J.startsWith("$2b$")||J.startsWith("$2y$"))return"bcrypt";if(J.startsWith("$argon2id$"))return"argon2id";if(J.startsWith("$argon2i$"))return"argon2i";if(J.startsWith("$argon2d$"))return"argon2d";return"unknown"}function g(J){let U=V(J),Q={};if(U==="bcrypt"){let Z=J.match(/^\$2[aby]\$(\d{2})\$/);if(Z&&Z[1]!==void 0)Q.rounds=Number.parseInt(Z[1],10)}else if(U==="argon2id"||U==="argon2i"||U==="argon2d"){let Z=J.match(/v=(\d+)/),$=J.match(/m=(\d+)/),G=J.match(/t=(\d+)/),X=J.match(/p=(\d+)/);if(Z&&Z[1]!==void 0)Q.version=Number.parseInt(Z[1],10);if($&&$[1]!==void 0)Q.memory=Number.parseInt($[1],10);if(G&&G[1]!==void 0)Q.rounds=Number.parseInt(G[1],10);if(X&&X[1]!==void 0)Q.parallelism=Number.parseInt(X[1],10)}return{algorithm:U,options:Q}}function NJ(J,U){let Q=g(J),Z=q(),$=U?.algorithm||Z.driver||"bcrypt",G=(j)=>{if(j==="argon2")return"argon2id";return j},X=G(Q.algorithm),F=G($);if(X!==F)return!0;if(X==="bcrypt"){let j=U?.rounds||Z.bcrypt?.rounds||12;if(Q.options.rounds!==j)return!0}if(X.startsWith("argon2")){let j=U?.memory||Z.argon2?.memory||65536,x=U?.time||Z.argon2?.time||2;if(Q.options.memory!==j||Q.options.rounds!==x)return!0}return!1}async function OJ(J,U){let Q=q(),Z=U?.algorithm||Q.driver||"bcrypt";if(Z==="bcrypt")return await c(J,U?.rounds);if(Z==="argon2"||Z==="argon2id"||Z==="argon2i"||Z==="argon2d")return await p(J,{type:Z==="argon2"?"argon2id":Z,memory:U?.memory,time:U?.time});throw Error(`Unsupported hashing algorithm: ${Z}`)}async function jJ(J,U){if(!J||!U)return!1;if(V(U)==="unknown")try{return await L(J,U)}catch{return!1}return await L(J,U)}async function c(J,U){let Q=q(),Z=U||Q.bcrypt?.rounds||12;return await T(J,{algorithm:"bcrypt",cost:Z})}async function p(J,U){let Q=q(),Z=U?.type||"argon2id",$=U?.memory||Q.argon2?.memory||65536,G=U?.time||Q.argon2?.time||2;return await T(J,{algorithm:Z,memoryCost:$,timeCost:G})}async function zJ(J,U){let Q=V(U);if(Q!=="argon2"&&Q!=="argon2i"&&Q!=="argon2id"&&Q!=="argon2d")return!1;return await L(J,U)}async function RJ(J,U){if(V(U)!=="bcrypt")return!1;return await L(J,U)}function DJ(J,U){let Q=u(U),Z=Buffer.from(Q),$=Buffer.from(J);if(Z.length!==$.length)return!1;return K(Z,$)}function LJ(J){return l(J)}function qJ(J,U){let Q=Buffer.from(J),Z=Buffer.from(U);if(Q.length!==Z.length)return K(Q,Q),!1;return K(Q,Z)}import{generateKey as d}from"ts-security-crypto";function MJ(){return d(32)}import{createHmac as n,timingSafeEqual as I}from"crypto";class N extends Error{reason;constructor(J,U="mismatch"){super(J);this.name="InvalidWebhookSignature",this.reason=U}}function P(J){if(!/^[0-9a-f]*$/i.test(J)||J.length%2!==0)throw new N("Signature is not valid hex","malformed");return Buffer.from(J,"hex")}function w(J,U){if(J.length!==U.length)return I(J,J),!1;return I(J,U)}function Y(J,U,Q){return n(Q,J).update(U).digest()}function h(J){let U=J.split(","),Q=Number.NaN,Z=[];for(let $ of U){let G=$.indexOf("=");if(G===-1)continue;let X=$.slice(0,G).trim(),F=$.slice(G+1).trim();if(X==="t")Q=Number.parseInt(F,10);else if(X==="v1")Z.push(F)}if(!Number.isFinite(Q)||Z.length===0)throw new N("Stripe-Signature header is malformed","malformed");return{timestamp:Q,signatures:Z}}function t(J,U,Q,Z={}){if(!J)throw new N("Stripe webhook secret is empty","missing");if(!U)throw new N("Stripe-Signature header is missing","missing");let{timestamp:$,signatures:G}=h(U),X=Z.toleranceSeconds??300;if(Math.floor((Z.now??Date.now())/1000)-$>X)throw new N("Stripe webhook timestamp is outside tolerance window","expired");let j=typeof Q==="string"?Q:Q.toString("utf8"),x=Y(J,`${$}.${j}`,"sha256");for(let k of G){let A;try{A=P(k)}catch{continue}if(w(x,A))return!0}throw new N("Stripe webhook signature did not match","mismatch")}function o(J,U,Q){if(!J)throw new N("GitHub webhook secret is empty","missing");if(!U)throw new N("X-Hub-Signature-256 header is missing","missing");let Z="sha256=";if(!U.startsWith(Z))throw new N("X-Hub-Signature-256 has wrong algorithm prefix","malformed");let $=P(U.slice(Z.length)),G=Y(J,Q,"sha256");if(!w(G,$))throw new N("GitHub webhook signature did not match","mismatch");return!0}function i(J,U,Q,Z="sha256"){if(!J)throw new N("HMAC secret is empty","missing");if(!Q)throw new N("Signature header is missing","missing");let $=Q.indexOf("="),G=$!==-1&&/^[a-z0-9]+$/i.test(Q.slice(0,$))?Q.slice($+1):Q,X=P(G),F=Y(J,U,Z);if(!w(F,X))throw new N("HMAC signature did not match","mismatch");return!0}function wJ(J,U,Q,Z,$={}){switch(J){case"stripe":return t(U,Q,Z,$);case"github":return o(U,Q,Z);case"generic":return i(U,Z,Q,$.algorithm??"sha256");default:throw new N(`Unknown webhook provider: ${String(J)}`,"malformed")}}export{wJ as verifyWebhook,t as verifyStripe,i as verifyHmac,jJ as verifyHash,o as verifyGithub,qJ as timingSafeEqualString,NJ as needsRehash,LJ as md5Encode,OJ as makeHash,OJ as make,JJ as legacyEncrypt,g as info,NJ as hashNeedsRehash,OJ as hashMake,g as hashInfo,V as hashDetectAlgorithm,jJ as hashCheck,MJ as generateAppKey,QJ as encrypt,V as detectAlgorithm,UJ as decrypt,jJ as check,RJ as bcryptVerify,c as bcryptEncode,DJ as base64Verify,FJ as base64Encode,zJ as argon2Verify,p as argon2Encode,ZJ as _resetCryptCacheForTests,N as InvalidWebhookSignature};
package/dist/key.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function generateAppKey(): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Verify a Stripe webhook signature.
3
+ *
4
+ * Stripe signs the concatenation `${timestamp}.${rawBody}` with HMAC-SHA256
5
+ * and includes both the timestamp and signature in the `Stripe-Signature`
6
+ * header. Verification:
7
+ * 1. Parse `t` and `v1=` from the header.
8
+ * 2. Compute HMAC-SHA256(`${t}.${body}`, secret).
9
+ * 3. Compare in constant time against any v1 entry.
10
+ * 4. Reject if `t` is older than `toleranceSeconds` to thwart replay.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * verifyStripe(secret, request.headers.get('stripe-signature')!, rawBody)
15
+ * ```
16
+ */
17
+ export declare function verifyStripe(secret: string, signatureHeader: string, body: string | Buffer, options?: VerifyWebhookOptions): true;
18
+ /**
19
+ * Verify a GitHub webhook signature.
20
+ *
21
+ * GitHub's `X-Hub-Signature-256` header is `sha256=<hex>` where the hex value
22
+ * is HMAC-SHA256(rawBody, secret). No timestamp is included — replay
23
+ * protection requires application-level idempotency keys (e.g. `X-GitHub-Delivery`).
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * verifyGithub(secret, request.headers.get('x-hub-signature-256')!, rawBody)
28
+ * ```
29
+ */
30
+ export declare function verifyGithub(secret: string, signatureHeader: string, body: string | Buffer): true;
31
+ /**
32
+ * Verify a generic HMAC signature where the header is just the hex digest.
33
+ *
34
+ * Useful for custom providers that don't bundle a timestamp or scheme prefix.
35
+ * The signature header may optionally start with the algorithm name and `=`
36
+ * (e.g. `sha256=abc...`) — that prefix is stripped if present.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * verifyHmac(secret, rawBody, request.headers.get('x-signature')!)
41
+ * verifyHmac(secret, rawBody, sig, 'sha512')
42
+ * ```
43
+ */
44
+ export declare function verifyHmac(secret: string, body: string | Buffer, signatureHeader: string, algorithm?: 'sha256' | 'sha512' | 'sha1'): true;
45
+ /**
46
+ * Verify a webhook signature against the named provider.
47
+ *
48
+ * Dispatches to provider-specific helpers — see {@link verifyStripe},
49
+ * {@link verifyGithub}, and {@link verifyHmac}. Throws
50
+ * {@link InvalidWebhookSignature} on any failure (malformed header, expired
51
+ * timestamp, mismatched HMAC, or missing input).
52
+ *
53
+ * @param provider 'stripe' | 'github' | 'generic'
54
+ * @param secret Provider's signing secret (`whsec_...` for Stripe, etc.)
55
+ * @param signature Raw header value as received from the request
56
+ * @param body Raw request body — must be the bytes-on-the-wire, not parsed JSON
57
+ * @param options See {@link VerifyWebhookOptions}
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import { verifyWebhook, InvalidWebhookSignature } from '@stacksjs/security'
62
+ *
63
+ * try {
64
+ * verifyWebhook('stripe', secret, req.headers.get('stripe-signature')!, rawBody)
65
+ * }
66
+ * catch (e) {
67
+ * if (e instanceof InvalidWebhookSignature)
68
+ * return new Response('Invalid signature', { status: 400 })
69
+ * throw e
70
+ * }
71
+ * ```
72
+ */
73
+ export declare function verifyWebhook(provider: WebhookProvider, secret: string, signature: string, body: string | Buffer, options?: VerifyWebhookOptions): true;
74
+ /**
75
+ * Options controlling webhook verification behavior.
76
+ */
77
+ export declare interface VerifyWebhookOptions {
78
+ toleranceSeconds?: number
79
+ algorithm?: 'sha256' | 'sha512' | 'sha1'
80
+ now?: number
81
+ }
82
+ /**
83
+ * Supported webhook providers with built-in signature schemes.
84
+ */
85
+ export type WebhookProvider = 'stripe' | 'github' | 'generic';
86
+ /**
87
+ * Thrown when a webhook signature fails verification.
88
+ *
89
+ * Catch this specifically to differentiate signature failures from generic
90
+ * errors. Returning a 400 (rather than 500) on this class is the standard
91
+ * response for failed webhook auth.
92
+ */
93
+ export declare class InvalidWebhookSignature extends Error {
94
+ readonly reason: 'malformed' | 'expired' | 'mismatch' | 'missing';
95
+ constructor(message: string, reason?: 'malformed' | 'expired' | 'mismatch' | 'missing');
96
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/security",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.88",
5
+ "version": "0.70.90",
6
6
  "description": "The Stacks framework security.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -55,10 +55,10 @@
55
55
  "ts-security-crypto": "0.0.2"
56
56
  },
57
57
  "devDependencies": {
58
- "@stacksjs/config": "0.70.88",
58
+ "@stacksjs/config": "0.70.90",
59
59
  "better-dx": "^0.2.16",
60
- "@stacksjs/env": "0.70.88",
61
- "@stacksjs/types": "0.70.88",
62
- "@stacksjs/validation": "0.70.88"
60
+ "@stacksjs/env": "0.70.90",
61
+ "@stacksjs/types": "0.70.90",
62
+ "@stacksjs/validation": "0.70.90"
63
63
  }
64
64
  }