@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.
- package/dist/index.d.mts +47 -0
- package/dist/index.mjs +131 -0
- package/package.json +20 -0
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|