@obsidianasecmx/obsidiana-protocol
v1.0.2
Published
Cryptographic protocol for secure client‑server communications with mutual authentication, forward secrecy, and MITM protection.
Maintainers
Readme
Obsidiana Protocol
A zero-dependency cryptographic protocol library built entirely on the Web Crypto API. Provides a complete suite of primitives for secure session establishment and encrypted communication.
Compatible with Node.js ≥ 18, modern browsers, and React Native 0.71+.
Table of Contents
Overview
Obsidiana Protocol implements a secure channel with the following properties:
- Ephemeral ECDH key exchange (P-256) — each session uses fresh keys generated in memory, providing forward secrecy.
- HKDF-SHA-256 key derivation (RFC 5869) — turns the raw shared secret into a non-extractable AES-GCM-256
CryptoKey. The salt is generated by the responder and transmitted in the handshake response so both sides converge on the same key. - AES-GCM-256 authenticated encryption — every message includes a GCM tag and an ECDSA-signed AAD block with timestamp, nonce, and HMAC session hints.
- ECDSA P-256 signatures — used both for general-purpose signing and for per-message AAD to prevent replay attacks, session confusion, and downgrade attacks.
- CBOR serialization (RFC 8949) — deterministic binary encoding with lexicographically sorted map keys.
- Zero dependencies — everything runs on the platform's native Web Crypto API.
Architecture
ObsidianaHandshake ← High-level session orchestrator
│
├── ObsidianaECDH ← Ephemeral P-256 key exchange
├── ObsidianaHKDF ← HKDF-SHA-256 key derivation
├── ObsidianaECDSA ← P-256 signing, AAD generation and verification
└── ObsidianaAES ← AES-GCM-256 encrypt/decrypt with signed AAD
ObsidianaCBOR ← Standalone RFC 8949 encoder/decoderHandshake flow:
Client Server
────── ──────
hs.init() hs.init()
hs.offer() ──── { d: pubKey } ────────► hs.complete({ offer })
└─ derives shared secret
└─ generates salt via HKDF
└─ generates sessionId (32-char hex)
└─ packs { pubKey, salt, sessionId }
◄─── { d: packed } ───────── returns { response, sessionId }
hs.complete({ response })
└─ derives same shared secret
└─ replicates key using received salt
◄──── Encrypted channel ────►
cipher.encrypt(data, { sessionId }) cipher.decrypt(envelope, { sessionId })The packed handshake response binary format:
pkLen (2B) | publicKey (pkLen B) | saltLen (2B) | salt (saltLen B) | sessionId (rest)Installation
npm install @obsidianasecmx/obsidiana-protocolconst {
ObsidianaHandshake,
ObsidianaECDH,
ObsidianaHKDF,
ObsidianaECDSA,
ObsidianaAES,
ObsidianaCBOR,
} = require("@obsidianasecmx/obsidiana-protocol");Quick Start
const { ObsidianaHandshake } = require("@obsidianasecmx/obsidiana-protocol");
const client = new ObsidianaHandshake();
const server = new ObsidianaHandshake();
await client.init();
await server.init();
// Key exchange
const offer = client.offer(); // { d: string }
const { response, sessionId } = await server.complete({ offer });
await client.complete({ response });
// Both sides now share the same AES-GCM-256 cipher
const envelope = await client.cipher.encrypt({ hello: "world" }, { sessionId });
const plaintext = await server.cipher.decrypt(envelope, { sessionId });
console.log(plaintext); // { hello: "world" }
console.log(sessionId); // "a3f8c2d1..." (32-char hex)Modules
ObsidianaHandshake
The high-level orchestrator that manages the full session establishment lifecycle. Internally uses ObsidianaECDH, ObsidianaHKDF, ObsidianaECDSA, and ObsidianaAES. After complete(), the instance exposes a ready-to-use cipher.
// Default — generates ephemeral keypairs
const hs = new ObsidianaHandshake();
// With a pre-set session ID (responder side, useful for testing)
const hs = new ObsidianaHandshake({ sessionId: "my-custom-session-id" });
// With a shared identity signer (for identity-bound sessions)
const hs = new ObsidianaHandshake({ signer: myECDSAInstance });Methods:
| Method | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| init() | Generates ephemeral ECDH keypair and ECDSA signer (if not provided). Must be called before anything else. |
| offer() | Returns { d: string } — the initiator's Base64-encoded ECDH public key. |
| complete({ offer }) | Responder mode. Derives shared secret, generates HKDF salt and session ID, initializes cipher, returns { response: { d: string }, sessionId: string }. |
| complete({ response }) | Initiator mode. Unpacks server response, replicates key from salt, initializes cipher. Returns {}. |
Properties (available after complete()):
| Property | Type | Description |
| -------------- | -------------- | ---------------------------------------------------------------------- |
| cipher | ObsidianaAES | Ready-to-use AES-GCM-256 cipher |
| sessionId | string | 32-char hex session identifier |
| sharedSecret | Uint8Array | Raw 32-byte ECDH shared secret |
| publicKey | string | This party's Base64-encoded ECDH public key (available after init()) |
ObsidianaAES
AES-GCM-256 encryption with ECDSA-signed AAD. Normally obtained via handshake.cipher, but can be instantiated directly with a CryptoKey and an ObsidianaECDSA signer.
Wire format of each encrypted envelope — { d: string } where d is Base64 of:
┌─────────────┬────────────┬──────────────────────┬──────────────────────────────┐
│ IV (12 B) │ AADLen (2B)│ AAD (JSON, variable) │ Ciphertext + GCM tag (16 B) │
└─────────────┴────────────┴──────────────────────┴──────────────────────────────┘The GCM authentication tag (16 bytes) is appended automatically by the Web Crypto API to the end of the ciphertext.
The AAD inner payload also embeds n (nonce), ts (timestamp), and h (per-message hint) alongside the encrypted data, making it tamper-evident even if the GCM tag were bypassed.
// Encrypt
const envelope = await cipher.encrypt({ userId: 42 }, { sessionId: "abc123" });
// envelope = { d: "BASE64_STRING..." }
// Decrypt
const data = await cipher.decrypt(envelope, { sessionId: "abc123" });
// data = { userId: 42 }
// Optional extra string mixed into the HMAC hint (for sub-channel binding)
const envelope = await cipher.encrypt(payload, {
sessionId,
extra: "channel-A",
});
const data = await cipher.decrypt(envelope, { sessionId, extra: "channel-A" });Each message is protected against:
| Threat | Mechanism |
| ----------------- | ------------------------------------------------------------------ | --- | --- | --- | --- | --------------------------- |
| Tampering | AES-GCM authentication tag + ECDSA-signed AAD |
| Replay attacks | Per-message random nonce + ±60s timestamp window |
| Session confusion | HMAC-derived per-message hint (h) and static hint (hs) in AAD |
| Downgrade attacks | Protocol version "obsidiana-v1" enforced in canonical AAD string |
| Forgery | ECDSA signature over v | ts | n | h | hs | k canonical representation |
Static helpers:
// Low-level blob packing (used internally, exposed for advanced use)
const blob = ObsidianaAES.packBlob(iv, aadBytes, cipherBytes);
const { iv, aadBytes, cipherBytes } = ObsidianaAES.unpackBlob(blob);ObsidianaECDH
Ephemeral ECDH P-256 key exchange. Private keys are never exported — the shared secret is derived in memory only.
Public keys are exported as Base64-encoded raw uncompressed SEC1 points (65 bytes: 0x04 prefix + 32-byte x + 32-byte y).
const alice = new ObsidianaECDH();
const bob = new ObsidianaECDH();
await alice.generateKeypair();
await bob.generateKeypair();
const alicePub = await alice.exportPublicKey(); // Base64, 65 bytes
const bobPub = await bob.exportPublicKey();
// Both derive the same 32-byte shared secret
const aliceSecret = await alice.deriveSharedSecret(bobPub);
const bobSecret = await bob.deriveSharedSecret(alicePub);
// aliceSecret deepEquals bobSecretStatic utility methods (used across the library):
// Buffer ↔ Base64 (processes in 64KB chunks to avoid call stack overflow)
const b64 = ObsidianaECDH.bufferToBase64(uint8Array);
const buf = ObsidianaECDH.base64ToBuffer(b64);ObsidianaHKDF
HKDF-SHA-256 key derivation (RFC 5869). Turns a raw 32-byte ECDH shared secret into a non-extractable AES-GCM-256 CryptoKey. The default context label is "obsidiana-protocol:v1".
The deriveWithSalt / deriveFromSalt pair is what ObsidianaHandshake uses: the responder generates a fresh random 32-byte salt, derives the key, and sends the salt in the handshake response so the initiator can reproduce the exact same key.
const hkdf = new ObsidianaHKDF();
// Simple derivation with a random internal salt (not reproducible)
const key = await hkdf.derive(sharedSecret);
// Responder: generate key + export salt for transmission
const { key, salt } = await hkdf.deriveWithSalt(sharedSecret);
// Initiator: reproduce the same key from the received salt
const sameKey = await hkdf.deriveFromSalt(sharedSecret, salt);
// Custom context label — different labels produce different keys from the same secret
const hkdf2 = new ObsidianaHKDF({ info: "my-app:data-channel-v2" });ObsidianaECDSA
ECDSA P-256 signing. Used for two distinct purposes:
- General-purpose signing — sign arbitrary
Uint8Arraydata, verify with a Base64 public key. - AAD generation — build and sign the per-message AAD object that
ObsidianaAESembeds in every encrypted envelope.
Keypairs live only in memory. For persistent identity keys, export them as JWK and reimport at startup (as ObsidianaIdentity in obsidiana-server does).
const signer = new ObsidianaECDSA();
await signer.generateKeypair();
// General-purpose signing
const data = new TextEncoder().encode("hello world");
const signature = await signer.sign(data); // Base64 ASN.1 DER
const publicKey = await signer.exportPublicKey(); // Base64, 65 bytes
const isValid = await ObsidianaECDSA.verify(publicKey, data, signature); // true
// AAD generation (used internally by ObsidianaAES)
const aad = await signer.signAAD({ sessionId: "abc123", extra: "optional" });
// aad = { v, ts, n, h, hs, k, s }
// AAD verification (checks timestamp window, HMAC hints, and ECDSA signature)
const valid = await signer.verifyAAD(aad, "abc123");
// Static hint derivation — 16-char hex derived from sessionId only
// Used for server-side session lookup without transmitting the real session ID
const hint = await ObsidianaECDSA.deriveStaticHint("my-session-id");AAD object structure:
| Field | Type | Description |
| ----- | -------- | ---------------------------------------------------------------------------- | --- | ---------------------------- | --- | --- | --- |
| v | string | Protocol version — always "obsidiana-v1" |
| ts | number | Unix timestamp in ms — rejected if abs(now - ts) > 60000 |
| n | string | Random 12-byte nonce (Base64) |
| h | string | Per-message HMAC hint — HMAC-SHA256(sessionId, ts | n | extra)[0:8] as 16 hex chars |
| hs | string | Static session hint — HMAC-SHA256(sessionId, "hs:v1")[0:8] as 16 hex chars |
| k | string | Signer's ECDH public key (Base64) |
| s | string | ECDSA signature over v | ts | n | h | hs | k |
Both h and hs are verified on decrypt — h proves the message belongs to this session at this timestamp/nonce, hs provides a stable lookup hint.
ObsidianaCBOR
CBOR (RFC 8949) encoder and decoder. Fully deterministic: object keys are always sorted lexicographically before encoding, and floating-point numbers always use 64-bit IEEE 754 representation. Semantic tags (major type 6) are decoded transparently — the tag number is ignored and the wrapped value is returned.
Supported types:
| JavaScript | CBOR major type |
| --------------------------- | ------------------------------ |
| null | Simple (22) |
| boolean | Simple (20 = false, 21 = true) |
| number (unsigned integer) | Unsigned (major 0) |
| number (negative integer) | Negative (major 1) |
| number (float) | Float 64-bit (major 7, 0xfb) |
| string | Text string (major 3) |
| Uint8Array | Byte string (major 2) |
| Array | Array (major 4) |
| Plain object | Map (major 5) — keys sorted |
// Encode
const bytes = ObsidianaCBOR.encode({
hello: "world",
count: 42,
data: new Uint8Array([1, 2, 3]),
nested: [true, null, -7],
});
// bytes is a Uint8Array
// Decode
const value = ObsidianaCBOR.decode(bytes);
// { count: 42, data: Uint8Array([1,2,3]), hello: "world", nested: [true, null, -7] }
// Note: keys are sorted because encoding sorted them
// Throws if buffer has trailing bytes
ObsidianaCBOR.decode(new Uint8Array([...bytes, 0x00])); // Error: trailing bytes
// Throws if input is not Uint8Array
ObsidianaCBOR.decode("not a buffer"); // Error: decode() expects Uint8ArrayUse Cases
1. Secure WebSocket communication
// server.js
const { ObsidianaHandshake } = require("@obsidianasecmx/obsidiana-protocol");
const sessions = new Map();
wss.on("connection", (socket) => {
const hs = new ObsidianaHandshake();
socket.on("message", async (raw) => {
const msg = JSON.parse(raw);
if (msg.type === "offer") {
await hs.init();
const { response, sessionId } = await hs.complete({ offer: msg.payload });
sessions.set(sessionId, hs);
socket.send(JSON.stringify({ type: "response", payload: response }));
return;
}
if (msg.type === "data") {
const data = await hs.cipher.decrypt(msg.payload, {
sessionId: hs.sessionId,
});
const reply = await hs.cipher.encrypt(
{ echo: data },
{ sessionId: hs.sessionId },
);
socket.send(JSON.stringify({ type: "data", payload: reply }));
}
});
});
// client.js
const client = new ObsidianaHandshake();
await client.init();
socket.send(JSON.stringify({ type: "offer", payload: client.offer() }));
socket.on("message", async (raw) => {
const msg = JSON.parse(raw);
if (msg.type === "response") {
await client.complete({ response: msg.payload });
const envelope = await client.cipher.encrypt(
{ hello: "secure" },
{ sessionId: client.sessionId },
);
socket.send(JSON.stringify({ type: "data", payload: envelope }));
}
});2. REST API with per-request encryption
// Establish session once, reuse cipher for all requests
const session = new ObsidianaHandshake();
await session.init();
const offerRes = await fetch("/api/handshake", {
method: "POST",
body: JSON.stringify(session.offer()),
headers: { "Content-Type": "application/json" },
});
await session.complete({ response: await offerRes.json() });
// All subsequent requests are encrypted
async function securePost(path, data) {
const envelope = await session.cipher.encrypt(data, {
sessionId: session.sessionId,
});
const res = await fetch(path, {
method: "POST",
body: JSON.stringify(envelope),
headers: { "Content-Type": "application/json" },
});
return session.cipher.decrypt(await res.json(), {
sessionId: session.sessionId,
});
}
const result = await securePost("/api/users/me", { update: { name: "Alice" } });3. End-to-end encrypted messaging with identity binding
const {
ObsidianaHandshake,
ObsidianaECDSA,
} = require("@obsidianasecmx/obsidiana-protocol");
// Each party has a long-lived ECDSA identity keypair
const aliceIdentity = new ObsidianaECDSA();
await aliceIdentity.generateKeypair();
const bobIdentity = new ObsidianaECDSA();
await bobIdentity.generateKeypair();
// Each thread uses a fresh ephemeral ECDH handshake bound to the identity signer
const alice = new ObsidianaHandshake({ signer: aliceIdentity });
await alice.init();
const bob = new ObsidianaHandshake({ signer: bobIdentity });
await bob.init();
const { response } = await bob.complete({ offer: alice.offer() });
await alice.complete({ response });
const msg = await alice.cipher.encrypt(
{ text: "Hey Bob!" },
{ sessionId: alice.sessionId },
);
const received = await bob.cipher.decrypt(msg, { sessionId: bob.sessionId });
console.log(received.text); // "Hey Bob!"4. Signed payloads without encryption (document signing)
const { ObsidianaECDSA } = require("@obsidianasecmx/obsidiana-protocol");
const signer = new ObsidianaECDSA();
await signer.generateKeypair();
const publicKey = await signer.exportPublicKey();
const document = JSON.stringify({ invoice: "INV-001", amount: 1500 });
const data = new TextEncoder().encode(document);
const signature = await signer.sign(data);
// Package and transmit
const signed = { document, signature, publicKey };
// Verify on receiving end (no keypair needed)
const isValid = await ObsidianaECDSA.verify(
signed.publicKey,
new TextEncoder().encode(signed.document),
signed.signature,
);
console.log(isValid); // true5. CBOR + encryption for compact binary payloads
const {
ObsidianaHandshake,
ObsidianaCBOR,
ObsidianaECDH,
} = require("@obsidianasecmx/obsidiana-protocol");
const client = new ObsidianaHandshake();
const server = new ObsidianaHandshake();
await client.init();
await server.init();
const { response } = await server.complete({ offer: client.offer() });
await client.complete({ response });
// CBOR-encode the payload, then encrypt the binary blob as Base64
const payload = { type: "reading", value: 23.7, ts: Date.now() };
const cborBytes = ObsidianaCBOR.encode(payload);
const envelope = await client.cipher.encrypt(
ObsidianaECDH.bufferToBase64(cborBytes),
{ sessionId: client.sessionId },
);
// Decrypt then CBOR-decode on the other side
const received = await server.cipher.decrypt(envelope, {
sessionId: server.sessionId,
});
const decoded = ObsidianaCBOR.decode(ObsidianaECDH.base64ToBuffer(received));
console.log(decoded); // { ts: ..., type: "reading", value: 23.7 }6. Multiple concurrent sessions
const { ObsidianaHandshake } = require("@obsidianasecmx/obsidiana-protocol");
const sessions = await Promise.all(
Array.from({ length: 10 }, async () => {
const client = new ObsidianaHandshake();
const server = new ObsidianaHandshake();
await Promise.all([client.init(), server.init()]);
const { response, sessionId } = await server.complete({
offer: client.offer(),
});
await client.complete({ response });
return { client, server, sessionId };
}),
);
// Each session is fully isolated — different keys, different session IDs
const results = await Promise.all(
sessions.map(async ({ client, server, sessionId }) => {
const enc = await client.cipher.encrypt({ id: sessionId }, { sessionId });
return server.cipher.decrypt(enc, { sessionId });
}),
);
console.log(results.map((r) => r.id)); // ['a3f8...', 'b1c2...', ...]Security Model
What is protected
| Threat | Mitigation |
| ------------------------ | ------------------------------------------------------------------------------------ | --- | --- | --- | --- | --------- |
| Passive eavesdropping | AES-GCM-256 encryption |
| Active tampering | GCM authentication tag (128-bit) + ECDSA-signed AAD |
| Replay attacks | Per-message random nonce + ±60s timestamp window checked on every decrypt |
| Session confusion | HMAC-derived per-message hint (h) and static hint (hs) verified on every decrypt |
| Downgrade attacks | Protocol version "obsidiana-v1" included in signed canonical AAD string |
| Session ID leakage | Real session IDs never transmitted — only 16-char HMAC-derived hints (hs) |
| Long-term key compromise | Ephemeral ECDH keypairs per session provide forward secrecy |
| AAD forgery | ECDSA P-256 signature over canonical v | ts | n | h | hs | k string |
What is not provided
- Mutual authentication —
ObsidianaHandshakedoes not authenticate identities by default. Pass a long-livedObsidianaECDSAinstance via thesigneroption to bind handshakes to known identities. - Key persistence — all keypairs live in memory. If you need sessions that survive restarts, store and reimport keys as JWK (the server-side
ObsidianaIdentitypattern demonstrates this). - Certificate infrastructure — there is no PKI. Public key distribution and trust management are the application's responsibility.
Cryptographic primitives
| Primitive | Standard | Parameters |
| -------------- | -------- | ---------------------------------------------------------------------------------- |
| Key exchange | ECDH | P-256 (secp256r1), ephemeral per session |
| Key derivation | HKDF | SHA-256, random 32-byte salt, 256-bit output, info label "obsidiana-protocol:v1" |
| Encryption | AES-GCM | 256-bit key, random 96-bit IV, 128-bit GCM tag |
| Signatures | ECDSA | P-256, SHA-256, ASN.1 DER output |
| Session hints | HMAC | SHA-256, first 8 bytes as 16 hex chars |
| Serialization | CBOR | RFC 8949, deterministic, sorted map keys, 64-bit floats |
API Reference
ObsidianaHandshake
new ObsidianaHandshake(options?)
options.sessionId? string — Pre-defined session ID (responder side)
options.signer? ObsidianaECDSA — Identity signer for AAD (optional)
.init() → Promise<this>
.offer() → { d: string } — initiator's public key
.complete({ offer }) → Promise<{ response: { d: string }, sessionId: string }>
.complete({ response }) → Promise<{}>
.cipher ObsidianaAES (available after complete())
.sessionId string (available after complete())
.publicKey string (available after init())
.sharedSecret Uint8Array (available after complete())ObsidianaAES
new ObsidianaAES(key: CryptoKey, signer: ObsidianaECDSA)
.encrypt(data, options?) → Promise<{ d: string }>
options.sessionId? string — default: "default"
options.extra? string — mixed into per-message HMAC hint
.decrypt(envelope, options?) → Promise<any>
options.sessionId? string — default: "default"
options.extra? string
static .packBlob(iv, aadBytes, cipherBytes) → Uint8Array
static .unpackBlob(blob) → { iv, aadBytes, cipherBytes }ObsidianaECDH
new ObsidianaECDH()
.generateKeypair() → Promise<this>
.exportPublicKey() → Promise<string> — Base64, 65 bytes (uncompressed SEC1)
.deriveSharedSecret(peerPubKeyB64) → Promise<Uint8Array> — 32 bytes
static .bufferToBase64(buf) → string
static .base64ToBuffer(b64) → Uint8ArrayObsidianaHKDF
new ObsidianaHKDF(options?)
options.info? string — context label (default: "obsidiana-protocol:v1")
options.salt? Uint8Array — fixed salt (optional)
.derive(sharedSecretBits) → Promise<CryptoKey>
.deriveWithSalt(sharedSecretBits) → Promise<{ key: CryptoKey, salt: string }>
.deriveFromSalt(sharedSecretBits, saltB64)→ Promise<CryptoKey>ObsidianaECDSA
new ObsidianaECDSA()
.generateKeypair() → Promise<this>
.sign(data: Uint8Array) → Promise<string> — Base64 ASN.1 DER signature
.exportPublicKey() → Promise<string> — Base64, 65 bytes
.signAAD(context) → Promise<ObsidianaAAD>
context.sessionId string
context.extra? string
.verifyAAD(aad, sessionId, extra?) → Promise<boolean>
static .verify(pubKeyB64, data, sigB64) → Promise<boolean>
static .deriveStaticHint(sessionId) → Promise<string> — 16 hex charsObsidianaAAD type:
{
v: string; // "obsidiana-v1"
ts: number; // timestamp ms
n: string; // random 12-byte nonce (Base64)
h: string; // per-message HMAC hint (16 hex chars)
hs: string; // static session HMAC hint (16 hex chars)
k: string; // signer's public key (Base64)
s: string; // ECDSA signature over "v|ts|n|h|hs|k"
}ObsidianaCBOR
static .encode(value) → Uint8Array
static .decode(buffer: Uint8Array) → any — throws on trailing bytes or invalid inputRunning Tests
node --test test/obsidiana.test.jsTests cover: CBOR round-trips for all supported types, ECDH shared secret agreement, HKDF determinism and salt reproduction, ECDSA sign/verify and AAD generation, AES-GCM encrypt/decrypt, full handshake flows in both directions, replay attack rejection, ±60s timestamp window enforcement, GCM tamper detection, AAD signature tamper detection, downgrade prevention, and concurrent session isolation.
License
GPL-3.0 — see LICENSE.
