Nostr: npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2
SAG and LSAG ring signatures on secp256k1.
Prove group membership without revealing identity.
- Anonymous group membership proofs — prove you are one of N key holders without revealing which one
- Whistleblowing — sign a statement as "a member of this organisation" without self-identifying
- Privacy-preserving attestation — "a licensed professional verified this" without naming the professional (pairs with nostr-attestations for on-chain anonymous endorsements)
- Double-action prevention (LSAG) — detect if the same key signs twice in the same context, without linking signatures across contexts
npm install @forgesworn/ring-sigThe snippets are self-contained. They generate throwaway keys with @noble/curves and @noble/hashes, which this package already depends on; add both to your own package.json if you import them directly.
Key formats: public keys are x-only hex (32 bytes, 64 hex characters; case-insensitive, normalised to lowercase). Private keys must be exactly 64 lowercase hex characters and below the curve order; anything else throws ValidationError.
import { schnorr } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { ringSign, ringVerify } from '@forgesworn/ring-sig';
// Four throwaway key pairs. schnorr.getPublicKey returns the x-only public key.
const members = Array.from({ length: 4 }, () => {
const secretKey = schnorr.utils.randomSecretKey();
return {
privateKey: bytesToHex(secretKey),
publicKey: bytesToHex(schnorr.getPublicKey(secretKey)),
};
});
// The ring is the list of x-only public keys (32 bytes, hex)
const ring = members.map(m => m.publicKey);
// Sign as ring member at index 2
const sig = ringSign('my message', ring, 2, members[2].privateKey);
// Anyone can verify the signature is from a ring member, using only the signature.
// Returns false (never throws) for an invalid or malformed signature.
const valid = ringVerify(sig); // trueimport { schnorr } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import {
lsagSign,
lsagVerify,
computeKeyImage,
hasDuplicateKeyImage,
type LsagSignature,
} from '@forgesworn/ring-sig';
const voters = Array.from({ length: 3 }, () => {
const secretKey = schnorr.utils.randomSecretKey();
return {
privateKey: bytesToHex(secretKey),
publicKey: bytesToHex(schnorr.getPublicKey(secretKey)),
};
});
const ring = voters.map(v => v.publicKey);
const electionId = 'vote-2026-q1';
// Voter side: sign a ballot as ring member 0 (needs the private key)
const sig = lsagSign('candidate-A', ring, 0, voters[0].privateKey, electionId);
// Verifier side: a vote verifier has no private keys and only needs the signature.
// sig.keyImage is the key image; it is deterministic per (key, electionId) pair.
const acceptedKeyImages: string[] = []; // key images of ballots already accepted
function acceptBallot(ballot: LsagSignature): boolean {
// lsagVerify checks the signature is internally consistent. It does not know which
// election or ring you expect, so compare those yourself.
if (ballot.electionId !== electionId) return false;
if (ballot.ring.join() !== ring.join()) return false;
if (!lsagVerify(ballot)) return false;
if (hasDuplicateKeyImage(ballot.keyImage, acceptedKeyImages)) return false; // double vote
acceptedKeyImages.push(ballot.keyImage);
return true;
}
acceptBallot(sig); // true
acceptBallot(sig); // false: same key image already recorded
// Signer side only: computeKeyImage needs the private key. It returns the same value
// as sig.keyImage, so a voter can check in advance whether their image is already spent.
const keyImage = computeKeyImage(voters[0].privateKey, voters[0].publicKey, electionId);
// keyImage === sig.keyImagesig.keyImage is a compressed point (66 hex characters). hasDuplicateKeyImage throws ValidationError if its first argument is not in that format, and expects existingKeyImages to be hex strings you stored from earlier sig.keyImage values.
- Ring: 2 to 1000 members, no duplicates.
messageis a string of at most 65536 UTF-8 bytes.electionIdis a non-empty string of at most 512 UTF-8 bytes.ringSign,lsagSignandcomputeKeyImagethrow on invalid input, mostlyValidationError(ring size, index, duplicates, key format, key mismatch, length limits). A ring entry that is not a valid x-only curve point surfaces the underlying@noble/curveserror, and a non-string ring entry aTypeError.ringVerifyandlsagVerifynever throw. Any malformed or invalid signature returnsfalse.- Scalars in a signature (
c0,responses) must be exactly 64 lowercase hex characters below the curve order. Signatures produced by this library always are.
Both ringSign and lsagSign accept an optional domain parameter (defaults to 'sag-v1' / 'lsag-v1'). Pass a custom domain if you need cross-protocol isolation. For ringSign it is the 5th argument; for lsagSign it is the 6th, after electionId:
import { schnorr } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { ringSign, ringVerify, lsagSign, lsagVerify } from '@forgesworn/ring-sig';
const members = Array.from({ length: 3 }, () => {
const secretKey = schnorr.utils.randomSecretKey();
return {
privateKey: bytesToHex(secretKey),
publicKey: bytesToHex(schnorr.getPublicKey(secretKey)),
};
});
const ring = members.map(m => m.publicKey);
const electionId = 'vote-2026-q1';
const sagSig = ringSign('my message', ring, 0, members[0].privateKey, 'my-app-v1');
const lsagSig = lsagSign('my message', ring, 0, members[0].privateKey, electionId, 'my-app-v1');
// The verify functions take only the signature and read the domain from it.
// There is no expected-domain parameter, so pin the domain yourself.
const APP_DOMAIN = 'my-app-v1';
const sagOk = sagSig.domain === APP_DOMAIN && ringVerify(sagSig); // true
const lsagOk = lsagSig.domain === APP_DOMAIN && lsagVerify(lsagSig); // trueWhen sig.domain is absent, the verifier uses the default ('sag-v1' for ringVerify, 'lsag-v1' for lsagVerify). Signing with the default domain omits the field, and an explicit default is equivalent, so to pin the default compare (sig.domain ?? 'lsag-v1') === 'lsag-v1'. The domain is part of every challenge hash, so editing domain on an existing signature makes it fail verification. A signature validly made under any other domain still verifies as it stands, which is why the check above is needed for isolation between protocols. In the same way, verification does not know which ring, message or electionId you expect: compare sig.ring, sig.message and sig.electionId with your own values.
RingSignatureError: base classValidationError: malformed inputs, bounds exceeded, non-canonical scalars, key mismatchCryptoError: cryptographic failure inside the library (hash-to-point exhaustion); not expected in practice
Breaking changes between major versions, with migration notes, are in CHANGELOG.md. Coming from v2: scalars are now strictly validated (see the 3.0.0 entry). Signatures produced by this library always verify; only hand-encoded or re-encoded scalars are rejected.
- llms.txt: concise API summary for LLMs and coding agents
- llms-full.txt: full API reference with worked examples (both files ship in the npm package)
- SAG (Spontaneous Anonymous Group): Schnorr-based ring signature. Signature size is O(n) with the ring.
- LSAG (Linkable SAG): Adds a deterministic key image
I = x * H_p(P || context). Signatures by the same key in the same context produce identical key images, enabling double-action detection without cross-context linkability. - Domain separation:
'sag-v1','lsag-v1','secp256k1-hash-to-point-v1' - Maximum ring size: 1000 members (
MAX_RING_SIZE, exported from the package entry point)
ForgeSworn builds open-source cryptographic identity, payments, and coordination tools for Nostr.
| Library | What it does |
|---|---|
| nsec-tree | Deterministic sub-identity derivation |
| ring-sig | SAG/LSAG ring signatures on secp256k1 |
| range-proof | Pedersen commitment range proofs |
| canary-kit | Coercion-resistant spoken verification |
| spoken-token | Human-speakable verification tokens |
| toll-booth | L402 payment middleware |
| geohash-kit | Geohash toolkit with polygon coverage |
| nostr-attestations | NIP-VA verifiable attestations |
| dominion | Epoch-based encrypted access control |
| nostr-veil | Privacy-preserving Web of Trust |
MIT