npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@obsidianasecmx/obsidiana-protocol

v1.0.2

Published

Cryptographic protocol for secure client‑server communications with mutual authentication, forward secrecy, and MITM protection.

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/decoder

Handshake 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-protocol
const {
  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 bobSecret

Static 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:

  1. General-purpose signing — sign arbitrary Uint8Array data, verify with a Base64 public key.
  2. AAD generation — build and sign the per-message AAD object that ObsidianaAES embeds 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 Uint8Array

Use 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); // true

5. 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 authenticationObsidianaHandshake does not authenticate identities by default. Pass a long-lived ObsidianaECDSA instance via the signer option 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 ObsidianaIdentity pattern 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)               → Uint8Array

ObsidianaHKDF

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 chars

ObsidianaAAD 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 input

Running Tests

node --test test/obsidiana.test.js

Tests 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.