@florianjs/opaque 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,47 @@
1
+ //#region src/types.d.ts
2
+ interface OpaqueConfig {
3
+ vaultUrl: string;
4
+ privateKey: string;
5
+ project: string;
6
+ env?: string;
7
+ }
8
+ type SecretsRecord = Record<string, string>;
9
+ //#endregion
10
+ //#region src/fetch.d.ts
11
+ declare function fetchSecrets(config: OpaqueConfig): Promise<SecretsRecord>;
12
+ //#endregion
13
+ //#region src/inject.d.ts
14
+ declare function injectEnv(secrets: SecretsRecord, target: Record<string, string | undefined>, options?: {
15
+ force?: boolean;
16
+ }): void;
17
+ //#endregion
18
+ //#region src/watch.d.ts
19
+ interface WatchOptions extends OpaqueConfig {
20
+ interval?: number;
21
+ onUpdate: (secrets: SecretsRecord) => void;
22
+ onError?: (err: Error) => void;
23
+ }
24
+ declare function watchSecrets(opts: WatchOptions): () => void;
25
+ //#endregion
26
+ //#region src/rotate.d.ts
27
+ interface RotateKeyResult {
28
+ privateKey: string;
29
+ publicKey: string;
30
+ }
31
+ declare function rotateKey(config: OpaqueConfig, adminToken: string): Promise<RotateKeyResult>;
32
+ //#endregion
33
+ //#region src/crypto.d.ts
34
+ interface SignRequestParams {
35
+ method: string;
36
+ url: string;
37
+ privateKey: string;
38
+ projectId: string;
39
+ }
40
+ interface SignedHeaders {
41
+ signature: string;
42
+ "signature-input": string;
43
+ "signature-agent": string;
44
+ }
45
+ declare function signRequest(params: SignRequestParams): Promise<SignedHeaders>;
46
+ //#endregion
47
+ export { type OpaqueConfig, type RotateKeyResult, type SecretsRecord, type SignRequestParams, type SignedHeaders, type WatchOptions, fetchSecrets, injectEnv, rotateKey, signRequest, watchSecrets };
package/dist/index.mjs ADDED
@@ -0,0 +1,131 @@
1
+ import * as ed from "@noble/ed25519";
2
+ //#region src/crypto.ts
3
+ ed.etc.sha512Sync = (..._msgs) => {
4
+ throw new Error("opaque: use async ed25519 methods only");
5
+ };
6
+ ed.etc.sha512Async = async (...msgs) => {
7
+ const data = new Uint8Array(msgs.reduce((acc, m) => acc + m.length, 0));
8
+ let offset = 0;
9
+ for (const msg of msgs) {
10
+ data.set(msg, offset);
11
+ offset += msg.length;
12
+ }
13
+ const hash = await crypto.subtle.digest("SHA-512", data);
14
+ return new Uint8Array(hash);
15
+ };
16
+ function bytesToHex(bytes) {
17
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
18
+ }
19
+ function bytesToBase64(bytes) {
20
+ let binary = "";
21
+ for (const byte of bytes) binary += String.fromCharCode(byte);
22
+ return btoa(binary);
23
+ }
24
+ function base64ToBytes(b64) {
25
+ const binary = atob(b64);
26
+ const bytes = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
28
+ return bytes;
29
+ }
30
+ function parseJwkPrivateKey(jwkString) {
31
+ const jwk = JSON.parse(jwkString);
32
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") throw new Error("opaque: invalid private key JWK — expected OKP Ed25519");
33
+ if (!jwk.d) throw new Error("opaque: invalid private key JWK — missing d parameter");
34
+ const b64 = jwk.d.replace(/-/g, "+").replace(/_/g, "/");
35
+ return base64ToBytes(b64 + "=".repeat((4 - b64.length % 4) % 4));
36
+ }
37
+ function buildCanonicalMessage(method, url, created, expires, nonce, keyId) {
38
+ const authority = new URL(url).host;
39
+ const targetUri = url;
40
+ const components = [
41
+ `"@method": ${method.toUpperCase()}`,
42
+ `"@authority": ${authority}`,
43
+ `"@target-uri": ${targetUri}`
44
+ ];
45
+ const sigParams = `("@method" "@authority" "@target-uri");created=${created};expires=${expires};nonce="${nonce}";keyid="${keyId}"`;
46
+ return [...components, `"@signature-params": ${sigParams}`].join("\n");
47
+ }
48
+ function generateNonce() {
49
+ const bytes = new Uint8Array(16);
50
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(bytes);
51
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
52
+ return bytesToHex(bytes);
53
+ }
54
+ async function signRequest(params) {
55
+ const { method, url, privateKey, projectId } = params;
56
+ const privateKeyBytes = parseJwkPrivateKey(privateKey);
57
+ const publicKeyHex = bytesToHex(await ed.getPublicKeyAsync(privateKeyBytes));
58
+ const created = Math.floor(Date.now() / 1e3);
59
+ const expires = created + 300;
60
+ const nonce = generateNonce();
61
+ const keyId = `${projectId}.agents.opaque.local`;
62
+ const canonicalMessage = buildCanonicalMessage(method, url, created, expires, nonce, keyId);
63
+ const messageBytes = new TextEncoder().encode(canonicalMessage);
64
+ const signatureBase64 = bytesToBase64(await ed.signAsync(messageBytes, privateKeyBytes));
65
+ const sigParams = `("@method" "@authority" "@target-uri");created=${created};expires=${expires};nonce="${nonce}";keyid="${keyId}"`;
66
+ return {
67
+ signature: `sig1=:${signatureBase64}:`,
68
+ "signature-input": `sig1=${sigParams}`,
69
+ "signature-agent": `sig1=${keyId};pubkey="${publicKeyHex}"`
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region src/fetch.ts
74
+ async function fetchSecrets(config) {
75
+ const env = config.env ?? (typeof process !== "undefined" ? process.env.NODE_ENV : "production") ?? "production";
76
+ const url = `${config.vaultUrl}/v1/secrets?env=${env}`;
77
+ const headers = await signRequest({
78
+ method: "GET",
79
+ url,
80
+ privateKey: config.privateKey,
81
+ projectId: config.project
82
+ });
83
+ const res = await fetch(url, {
84
+ method: "GET",
85
+ headers
86
+ });
87
+ if (!res.ok) {
88
+ const body = await res.text();
89
+ throw new Error(`opaque: failed to fetch secrets (${res.status}) — ${body}`);
90
+ }
91
+ return res.json();
92
+ }
93
+ //#endregion
94
+ //#region src/inject.ts
95
+ function injectEnv(secrets, target, options) {
96
+ for (const [key, value] of Object.entries(secrets)) if (options?.force || target[key] === void 0) target[key] = value;
97
+ }
98
+ //#endregion
99
+ //#region src/watch.ts
100
+ function watchSecrets(opts) {
101
+ const interval = opts.interval ?? 6e4;
102
+ const poll = async () => {
103
+ try {
104
+ opts.onUpdate(await fetchSecrets(opts));
105
+ } catch (err) {
106
+ opts.onError?.(err);
107
+ }
108
+ };
109
+ poll();
110
+ const timer = setInterval(() => void poll(), interval);
111
+ return () => clearInterval(timer);
112
+ }
113
+ //#endregion
114
+ //#region src/rotate.ts
115
+ async function rotateKey(config, adminToken) {
116
+ const url = `${config.vaultUrl}/v1/admin/projects/${encodeURIComponent(config.project)}/rotate`;
117
+ const res = await fetch(url, {
118
+ method: "PUT",
119
+ headers: {
120
+ authorization: `Bearer ${adminToken}`,
121
+ "content-type": "application/json"
122
+ }
123
+ });
124
+ if (!res.ok) {
125
+ const body = await res.text();
126
+ throw new Error(`opaque: failed to rotate key (${res.status}) — ${body}`);
127
+ }
128
+ return await res.json();
129
+ }
130
+ //#endregion
131
+ export { fetchSecrets, injectEnv, rotateKey, signRequest, watchSecrets };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@florianjs/opaque",
3
+ "version": "0.1.0",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "types": "./dist/index.d.mts"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@noble/ed25519": "^2.2.3"
16
+ },
17
+ "scripts": {
18
+ "pack": "vp pack"
19
+ }
20
+ }