bridle-core 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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/dist/src/envelope.d.ts +101 -0
- package/dist/src/envelope.d.ts.map +1 -0
- package/dist/src/envelope.js +137 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/identity.d.ts +31 -0
- package/dist/src/identity.d.ts.map +1 -0
- package/dist/src/identity.js +63 -0
- package/dist/src/identity.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/policy.d.ts +59 -0
- package/dist/src/policy.d.ts.map +1 -0
- package/dist/src/policy.js +190 -0
- package/dist/src/policy.js.map +1 -0
- package/dist/src/redact.d.ts +8 -0
- package/dist/src/redact.d.ts.map +1 -0
- package/dist/src/redact.js +54 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/render.d.ts +15 -0
- package/dist/src/render.d.ts.map +1 -0
- package/dist/src/render.js +44 -0
- package/dist/src/render.js.map +1 -0
- package/dist/src/seal.d.ts +32 -0
- package/dist/src/seal.d.ts.map +1 -0
- package/dist/src/seal.js +60 -0
- package/dist/src/seal.js.map +1 -0
- package/dist/src/transport.d.ts +14 -0
- package/dist/src/transport.d.ts.map +1 -0
- package/dist/src/transport.js +34 -0
- package/dist/src/transport.js.map +1 -0
- package/dist/test/core.test.d.ts +2 -0
- package/dist/test/core.test.d.ts.map +1 -0
- package/dist/test/core.test.js +310 -0
- package/dist/test/core.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +46 -0
- package/src/envelope.ts +196 -0
- package/src/identity.ts +81 -0
- package/src/index.ts +7 -0
- package/src/policy.ts +243 -0
- package/src/redact.ts +59 -0
- package/src/render.ts +50 -0
- package/src/seal.ts +100 -0
- package/src/transport.ts +31 -0
package/src/seal.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateKeyPairSync,
|
|
3
|
+
diffieHellman,
|
|
4
|
+
createPublicKey,
|
|
5
|
+
createPrivateKey,
|
|
6
|
+
createCipheriv,
|
|
7
|
+
createDecipheriv,
|
|
8
|
+
hkdfSync,
|
|
9
|
+
randomBytes,
|
|
10
|
+
timingSafeEqual,
|
|
11
|
+
} from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Payload sealing.
|
|
15
|
+
*
|
|
16
|
+
* Signing keys prove who sent an envelope; these keys decide who can read one.
|
|
17
|
+
* The relay handles sealed boxes only — it can verify a signature and route by
|
|
18
|
+
* name, and it cannot open the payload. That is what makes "self-host the
|
|
19
|
+
* coordination server, or don't, the relay still can't read your work" true
|
|
20
|
+
* rather than a promise.
|
|
21
|
+
*
|
|
22
|
+
* X25519 ECDH with an ephemeral sender key -> HKDF-SHA256 -> AES-256-GCM.
|
|
23
|
+
* The envelope id is bound in as additional data, so a sealed box cannot be
|
|
24
|
+
* lifted out of one envelope and replayed inside another.
|
|
25
|
+
*/
|
|
26
|
+
export interface SealedBox {
|
|
27
|
+
/** Ephemeral sender public key, base64 SPKI DER. */
|
|
28
|
+
epk: string;
|
|
29
|
+
iv: string;
|
|
30
|
+
ct: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SealingKeys {
|
|
35
|
+
publicKey: string;
|
|
36
|
+
privateKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function generateSealingKeys(): SealingKeys {
|
|
40
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
41
|
+
return {
|
|
42
|
+
publicKey: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
43
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pub = (b64: string) =>
|
|
48
|
+
createPublicKey({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
49
|
+
const priv = (b64: string) =>
|
|
50
|
+
createPrivateKey({ key: Buffer.from(b64, "base64"), format: "der", type: "pkcs8" });
|
|
51
|
+
|
|
52
|
+
function deriveKey(shared: Buffer, epkB64: string, recipientB64: string, aad: string): Buffer {
|
|
53
|
+
const salt = Buffer.from(epkB64 + "|" + recipientB64, "utf8");
|
|
54
|
+
return Buffer.from(hkdfSync("sha256", shared, salt, Buffer.from("bridle/seal/v1|" + aad, "utf8"), 32));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function seal(plaintext: Buffer, recipientPublicKeyB64: string, aad: string): SealedBox {
|
|
58
|
+
const eph = generateKeyPairSync("x25519");
|
|
59
|
+
const epk = eph.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
60
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: pub(recipientPublicKeyB64) });
|
|
61
|
+
const key = deriveKey(shared, epk, recipientPublicKeyB64, aad);
|
|
62
|
+
const iv = randomBytes(12);
|
|
63
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
64
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
65
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
66
|
+
return {
|
|
67
|
+
epk,
|
|
68
|
+
iv: iv.toString("base64"),
|
|
69
|
+
ct: ct.toString("base64"),
|
|
70
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class SealError extends Error {}
|
|
75
|
+
|
|
76
|
+
export function open(box: SealedBox, recipient: SealingKeys, aad: string): Buffer {
|
|
77
|
+
let shared: Buffer;
|
|
78
|
+
try {
|
|
79
|
+
shared = diffieHellman({ privateKey: priv(recipient.privateKey), publicKey: pub(box.epk) });
|
|
80
|
+
} catch {
|
|
81
|
+
throw new SealError("malformed ephemeral key");
|
|
82
|
+
}
|
|
83
|
+
const key = deriveKey(shared, box.epk, recipient.publicKey, aad);
|
|
84
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
|
|
85
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
86
|
+
decipher.setAuthTag(Buffer.from(box.tag, "base64"));
|
|
87
|
+
try {
|
|
88
|
+
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new SealError("could not open sealed payload: wrong recipient, or it was tampered with");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Constant-time compare, for anywhere we check a token or a code. */
|
|
95
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
96
|
+
const ab = Buffer.from(a, "utf8");
|
|
97
|
+
const bb = Buffer.from(b, "utf8");
|
|
98
|
+
if (ab.length !== bb.length) return false;
|
|
99
|
+
return timingSafeEqual(ab, bb);
|
|
100
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { canonical, validateEnvelope, validatePayload, type Envelope, type Payload } from "./envelope.js";
|
|
2
|
+
import { seal, open as openBox, type SealingKeys } from "./seal.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Moves the payload into a sealed box addressed to the recipient. Do this
|
|
6
|
+
* *before* signing, so the signature covers the ciphertext that actually travels.
|
|
7
|
+
*/
|
|
8
|
+
export function sealEnvelope(env: Envelope, recipientSealPublicKey: string): Envelope {
|
|
9
|
+
if (env.sealed) throw new Error("envelope is already sealed");
|
|
10
|
+
if (!env.payload) throw new Error("nothing to seal");
|
|
11
|
+
if (env.sig) throw new Error("seal before signing, not after");
|
|
12
|
+
const box = seal(Buffer.from(canonical(env.payload), "utf8"), recipientSealPublicKey, env.id);
|
|
13
|
+
const { payload: _drop, ...rest } = env;
|
|
14
|
+
return { ...rest, sealed: box };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opens a sealed envelope on the receiving machine and re-validates the payload.
|
|
19
|
+
* A payload is never trusted just because it decrypted — it still has to be a
|
|
20
|
+
* well-formed payload for the verb it claims.
|
|
21
|
+
*/
|
|
22
|
+
export function openEnvelope(env: Envelope, keys: SealingKeys): Envelope {
|
|
23
|
+
if (!env.sealed) throw new Error("envelope is not sealed");
|
|
24
|
+
const plain = openBox(env.sealed, keys, env.id);
|
|
25
|
+
const payload = JSON.parse(plain.toString("utf8")) as Payload;
|
|
26
|
+
validatePayload(env.verb, payload as unknown as Record<string, unknown>);
|
|
27
|
+
const { sealed: _drop, ...rest } = env;
|
|
28
|
+
const opened = { ...rest, payload } as Envelope;
|
|
29
|
+
validateEnvelope(opened);
|
|
30
|
+
return opened;
|
|
31
|
+
}
|