@solidus-network/auth 0.6.1 → 0.6.2

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/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # @solidus-network/auth
2
2
 
3
- DID-based authentication primitives for the [Solidus Network](https://solidus.network) protocol.
3
+ DID-based authentication and DID-less BBS+ selective-disclosure verification for the [Solidus Network](https://solidus.network) protocol.
4
4
 
5
- Ed25519 challenge / signature / verification, designed to drop into any Node.js or edge backend that needs to authenticate a holder of a DID without operating its own user database.
5
+ Two independent surfaces, one package:
6
+
7
+ - **DID-bound auth** — Ed25519 challenge / signature / verification, for backends that authenticate the holder of a known DID without operating a user database.
8
+ - **DID-less presentations** — the relying-party surface for Solidus's unlinkable credential presentations: verify "a valid KYC credential from this issuer, disclosing exactly these claims" without ever learning who presented it.
6
9
 
7
10
  ## Install
8
11
 
@@ -12,39 +15,146 @@ npm install @solidus-network/auth
12
15
  pnpm add @solidus-network/auth
13
16
  ```
14
17
 
15
- ## Quick start
18
+ ## Quick start — DID-bound challenge
19
+
20
+ ```ts
21
+ import { createChallenge, verifyPresentation } from '@solidus-network/auth'
22
+
23
+ // Server: issue a challenge for a known DID (TTL in seconds, default 300)
24
+ const challenge = createChallenge('did:solidus:testnet:abc123', 300)
25
+
26
+ // Client answers with a W3C Verifiable Presentation whose proof signs
27
+ // the challenge nonce; the server verifies:
28
+ const result = await verifyPresentation(challenge, presentation, getPublicKey)
29
+
30
+ if (result.valid) {
31
+ // authenticated — result.checks itemizes expiry/holder/nonce/signature
32
+ }
33
+ ```
34
+
35
+ ## DID-less selective-disclosure presentations (BBS+)
36
+
37
+ The verifier surface for Solidus's **unlinkable** presentations: a wallet
38
+ proves it holds a valid KYC credential from a trusted issuer and disclosed
39
+ exactly the claims you asked for — without revealing the holder's DID, a
40
+ credential ID, or anything two relying parties could later compare to
41
+ discover they served the same person.
42
+
43
+ ### How it works
44
+
45
+ - The issuer signs an 8-message KYC vector with BBS+ under a **header that
46
+ is constant per (issuer, credential-type, epoch)** — no per-credential
47
+ entropy, so the header places a holder in a cohort, never identifies one.
48
+ - The issuer publishes its per-epoch public keys at
49
+ `https://verify.solidus.network/.well-known/solidus-bbs-issuer.json`.
50
+ Credentials live as long as their epoch (+ a grace window); revocation is
51
+ bounded-lifetime — a lapsed epoch's key disappears from the document and
52
+ every verifier fails closed.
53
+ - The wallet answers a verifier's challenge with a **randomized proof**
54
+ bound to that verifier's domain + nonce. Replaying it under any other
55
+ challenge fails cryptographically.
56
+ - The envelope (`BbsSelectiveDisclosurePresentation`) has **no `holder`
57
+ field by construction**, and the verifier rejects any envelope that
58
+ discloses `subject_did`, `verification_id`, or `document_hash` even when
59
+ the proof itself is valid.
60
+
61
+ ### Verifier quickstart (a complete relying party)
16
62
 
17
63
  ```ts
18
- import { generateChallenge, verifyChallenge } from '@solidus-network/auth'
64
+ import {
65
+ createBbsChallenge,
66
+ verifyBbsPresentation,
67
+ createIssuerKeyResolver,
68
+ parseBbsPresentation,
69
+ decodeDisclosedKycFields,
70
+ } from '@solidus-network/auth'
19
71
 
20
- // Server: issue a challenge
21
- const challenge = generateChallenge({
22
- did: 'did:solidus:testnet:abc123',
23
- domain: 'app.example.com',
24
- ttlMs: 5 * 60_000,
72
+ // 1. Issue a DID-less challenge (store it; enforce single use).
73
+ const challenge = createBbsChallenge({
74
+ domain: 'your-app.example.com', // your authenticated origin
75
+ credentialType: 'kyc',
76
+ requiredIndices: [4, 5], // kyc_level, country
25
77
  })
26
78
 
27
- // Client signs `challenge.nonce` with the DID's signing key, returns the signature
79
+ // 2. The wallet answers with a serialized envelope.
80
+ const presentation = parseBbsPresentation(envelopeJson)
28
81
 
29
- // Server: verify
30
- const result = await verifyChallenge({
31
- challenge,
32
- signature,
33
- publicKey,
82
+ // 3. Verify — stateless, against the issuer's published epoch keys.
83
+ const resolveIssuerKey = createIssuerKeyResolver({
84
+ issuerBaseUrl: 'https://verify.solidus.network',
85
+ })
86
+ const result = await verifyBbsPresentation(challenge, presentation, {
87
+ issuerDid: 'did:solidus:testnet:2FDp7gH5qyb66jjsXAbFQYwLygqQ',
88
+ resolveIssuerKey,
89
+ used: false, // your storage-side single-use flag
34
90
  })
35
91
 
36
- if (result.ok) {
37
- // authenticated
92
+ if (result.valid) {
93
+ const fields = decodeDisclosedKycFields(presentation.disclosed)
94
+ // e.g. { issuer_did: '…', kyc_level: '2', country: 'TR' } — and nothing else
38
95
  }
39
96
  ```
40
97
 
98
+ The resolver is fail-closed: an unknown or lapsed epoch, an unreachable
99
+ issuer, or a malformed key document all resolve to `null` and the
100
+ presentation is rejected — never accepted on error.
101
+
102
+ ### Verify the unlinkability claim yourself
103
+
104
+ Don't take our word for it — the demonstration ships in this package and
105
+ runs in under a minute:
106
+
107
+ ```sh
108
+ node node_modules/@solidus-network/auth/demo/unlinkability-demo.mjs
109
+ ```
110
+
111
+ It issues one credential, presents it to two relying parties, verifies
112
+ both presentations, then pools everything both RPs received and shows the
113
+ complete correlation surface: cohort-level constants and the disclosed
114
+ claims — no per-holder value in common, randomized proofs sharing no
115
+ bytes, and cross-challenge replay failing.
116
+
117
+ ### Re-recognition (pairwise DIDs)
118
+
119
+ When a returning user *wants* to be recognized by one relying party, the
120
+ wallet can attach a **pairwise DID** — deterministically derived from the
121
+ holder's seed *and that verifier's identity*, so the same user shows a
122
+ different, uncorrelatable pairwise DID to every other verifier. See
123
+ `buildPairwiseAttachment` / `buildPairwiseDid`. Two caveats, stated
124
+ plainly: the wallet must derive against the verifier's *authenticated*
125
+ origin (never an ID copied blindly from the challenge), and the pairwise
126
+ signature sits *beside* the credential proof rather than being
127
+ cryptographically bound to the same seed — assurance is "a returning
128
+ controller of this key who also holds a valid credential", which is right
129
+ for convenience and analytics, not for access-control-grade identity.
130
+
131
+ ### What this does and does not protect
132
+
133
+ **Protected (cryptographic presentation layer):** two relying parties —
134
+ or the same one twice — cannot link presentations by anything in the
135
+ envelopes: no subject DID, no credential ID, no document hash, randomized
136
+ proofs, cohort-constant headers, challenge-bound replay protection.
137
+
138
+ **NOT protected (out of scope — be honest with your users):** network-level
139
+ correlation (IP address, timing, TLS/device fingerprints, cookies), and
140
+ relying parties pooling out-of-band data they each collected themselves
141
+ (email, phone, payment details). If your application hands every RP the
142
+ user's email address, unlinkable credentials will not make them unlinkable.
143
+
144
+ **Status:** the underlying `@solidus-network/bbs` implementation is
145
+ **testnet-grade and audit-pending** (external audit via NLnet NGI Zero,
146
+ H2 2026 target). `did:solidus` is submitted to the W3C DID method registry
147
+ and under review. This runs on the Solidus testnet — do not represent it
148
+ as audited or production-assured until those complete.
149
+
41
150
  ## Features
42
151
 
43
152
  - Ed25519 signature verification via [@noble/ed25519](https://www.npmjs.com/package/@noble/ed25519)
44
153
  - BLAKE3 hashing for challenge digests via [@noble/hashes](https://www.npmjs.com/package/@noble/hashes)
45
154
  - Time-bounded challenges with TTL enforcement
46
155
  - Domain-bound challenges (prevents replay across origins)
47
- - Zero runtime dependencies on a database or framework
156
+ - DID-less BBS+ selective-disclosure verification with fail-closed epoch-key resolution
157
+ - A runnable unlinkability demonstration (`demo/unlinkability-demo.mjs`)
48
158
 
49
159
  ## License
50
160
 
@@ -0,0 +1,159 @@
1
+ // Solidus unlinkability demonstration — runnable by anyone, from npm alone.
2
+ //
3
+ // npm i @solidus-network/auth @solidus-network/bbs
4
+ // node node_modules/@solidus-network/auth/demo/unlinkability-demo.mjs
5
+ //
6
+ // You play every role: issuer, holder, and two relying parties who later
7
+ // collude. The issuer keypair is generated locally; the FORMAT — header v2,
8
+ // the 8-message KYC vector, the envelope, the presentation-header binding —
9
+ // is exactly what verify.solidus.network issues in production (Part 1
10
+ // fetches its live epoch-key document so you can see that for yourself).
11
+ // Production differs only in whose secret key signs, never in what a
12
+ // relying party receives — so the correlation analysis below transfers.
13
+ //
14
+ // HONESTY BOUNDARY (read before quoting this): this demonstrates
15
+ // unlinkability at the cryptographic presentation layer ONLY. It does NOT
16
+ // defend against network-level correlation (IP address, timing, device
17
+ // fingerprint, cookies) or relying parties pooling out-of-band data they
18
+ // collected themselves (email, phone number). @solidus-network/bbs is
19
+ // testnet-grade; external audit pending (NLnet NGI Zero, H2 2026 target).
20
+
21
+ import {
22
+ createBbsChallenge,
23
+ verifyBbsPresentation,
24
+ buildBbsPresentation,
25
+ createIssuerKeyResolver,
26
+ KYC_MESSAGE_FIELDS,
27
+ } from '@solidus-network/auth'
28
+ import {
29
+ BbsSecretKey,
30
+ buildV2Header,
31
+ derivePresentationHeader,
32
+ utf8,
33
+ } from '@solidus-network/bbs'
34
+
35
+ const hex = (b) => Buffer.from(b).toString('hex')
36
+ const line = (s = '') => console.log(s)
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // PART 1 — the production issuer publishes epoch keys at a public URL.
40
+ // Anyone can resolve them; unknown or lapsed epochs fail closed to null.
41
+ // ---------------------------------------------------------------------------
42
+ line('== PART 1: production issuer key document (public URL) ==')
43
+ const PROD = 'https://verify.solidus.network'
44
+ try {
45
+ const doc = await (await fetch(`${PROD}/.well-known/solidus-bbs-issuer.json`)).json()
46
+ line(`issuer_did: ${doc.issuer_did}`)
47
+ line(`current epoch: ${doc.current.epoch} (notAfter ${doc.current.notAfter})`)
48
+ const resolve = createIssuerKeyResolver({ issuerBaseUrl: PROD })
49
+ const key = await resolve(doc.current.epoch)
50
+ line(`resolver → epoch ${doc.current.epoch}: ${key ? key.slice(0, 32) + '… (96-byte BLS12-381 G2 key)' : 'null'}`)
51
+ const lapsed = await resolve(9999)
52
+ line(`resolver → epoch 9999 (unknown): ${lapsed === null ? 'null (fail-closed)' : 'UNEXPECTED KEY'}`)
53
+ } catch {
54
+ line('(offline — skipping the live fetch; the local demonstration below is unaffected)')
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // PART 2 — one credential, two relying parties.
59
+ // ---------------------------------------------------------------------------
60
+ line()
61
+ line('== PART 2: one credential, presented to two relying parties ==')
62
+ const issuerDid = 'did:solidus:testnet:demo-issuer'
63
+ const credentialType = 'kyc'
64
+ const epoch = 2
65
+ const sk = await BbsSecretKey.generate()
66
+ const pk = await sk.publicKey()
67
+ const header = buildV2Header(issuerDid, credentialType, epoch)
68
+
69
+ // The production message vector (order frozen). Demo values only — no PII.
70
+ const messages = [
71
+ utf8('did:solidus:testnet:demoHolderSubjectDid0000'), // 0 subject_did — ALWAYS hidden
72
+ utf8(issuerDid), // 1 issuer_did
73
+ utf8('vrf_demo_0001'), // 2 verification_id — ALWAYS hidden
74
+ utf8('2026-07-01T09:30:00Z'), // 3 verified_at
75
+ utf8('2'), // 4 kyc_level
76
+ utf8('TR'), // 5 country
77
+ utf8('passport'), // 6 document_type
78
+ utf8('deadbeefdeadbeefdeadbeefdeadbeef'), // 7 document_hash — ALWAYS hidden
79
+ ]
80
+ const sig = await sk.sign(header, messages)
81
+ line(`issued: 1 credential, 8 messages, header v2 = "${Buffer.from(header).toString()}"`)
82
+ line('(header v2 is issuer/type/epoch-constant — identical for every holder in the cohort)')
83
+
84
+ const resolveLocal = async (e) => (e === epoch ? pk.toHex() : null)
85
+ const rpA = createBbsChallenge({ domain: 'rp-a.example.com', credentialType, requiredIndices: [4, 5] })
86
+ const rpB = createBbsChallenge({ domain: 'rp-b.example.com', credentialType, requiredIndices: [4] })
87
+
88
+ async function present(challenge) {
89
+ const ph = derivePresentationHeader(challenge.domain, Buffer.from(challenge.nonce, 'hex'))
90
+ const disclosedIndices = [1, ...challenge.requiredIndices].sort((x, y) => x - y)
91
+ const proof = await sig.createProof({ pk, header, presentationHeader: ph, messages, disclosedIndices })
92
+ return buildBbsPresentation({
93
+ challengeId: challenge.id,
94
+ epoch,
95
+ proofHex: proof.toHex(),
96
+ totalMessageCount: messages.length,
97
+ disclosed: disclosedIndices.map((i) => ({ index: i, message: hex(messages[i]) })),
98
+ })
99
+ }
100
+ const presA = await present(rpA)
101
+ const presB = await present(rpB)
102
+
103
+ const opts = () => ({ issuerDid, resolveIssuerKey: resolveLocal, used: false, network: 'testnet' })
104
+ const resA = await verifyBbsPresentation(rpA, presA, opts())
105
+ const resB = await verifyBbsPresentation(rpB, presB, opts())
106
+ line(`RP A verify: valid=${resA.valid}`)
107
+ line(`RP B verify: valid=${resB.valid}`)
108
+ if (!resA.valid || !resB.valid) throw new Error('verification failed — demo aborted')
109
+
110
+ const replay = await verifyBbsPresentation(rpB, { ...presA, challengeId: rpB.id }, opts())
111
+ line(`RP B replaying A's proof under its own challenge: valid=${replay.valid} (must be false)`)
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // PART 3 — the collusion attempt: both RPs pool everything they received.
115
+ // ---------------------------------------------------------------------------
116
+ line()
117
+ line('== PART 3: RP A + RP B collude — the complete correlation surface ==')
118
+ const jsonA = JSON.stringify(presA)
119
+ const jsonB = JSON.stringify(presB)
120
+
121
+ for (const [name, needle] of [
122
+ ['subject DID', 'demoHolderSubjectDid0000'],
123
+ ['verification_id', hex(utf8('vrf_demo_0001'))],
124
+ ['document_hash', hex(utf8('deadbeefdeadbeefdeadbeefdeadbeef'))],
125
+ ]) {
126
+ const found = jsonA.includes(needle) || jsonB.includes(needle)
127
+ line(`per-holder ${name} present in either envelope: ${found ? 'YES — LINKABLE (FAIL)' : 'no'}`)
128
+ if (found) throw new Error('linkable identifier leaked')
129
+ }
130
+
131
+ const a = Buffer.from(presA.proof, 'hex')
132
+ const b = Buffer.from(presB.proof, 'hex')
133
+ let equalBytes = 0
134
+ for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) equalBytes++
135
+ line(`proof bytes equal at the same offset: ${equalBytes}/${Math.min(a.length, b.length)} (random baseline ≈ ${(Math.min(a.length, b.length) / 256).toFixed(1)})`)
136
+
137
+ const presA2 = await present(rpA)
138
+ line(`regenerated proof for the SAME challenge identical to the first: ${presA2.proof === presA.proof ? 'YES (FAIL)' : 'no (randomized)'}`)
139
+
140
+ line('fields present in BOTH envelopes:')
141
+ for (const [k, v] of Object.entries(presA)) {
142
+ if (JSON.stringify(v) === JSON.stringify(presB[k])) line(` ${k} = ${JSON.stringify(v)} ← cohort-constant, shared by every holder of this (issuer, type, epoch)`)
143
+ }
144
+ const inA = Object.fromEntries(presA.disclosed.map((d) => [d.index, d.message]))
145
+ for (const d of presB.disclosed) {
146
+ if (inA[d.index]) line(` disclosed[${d.index}] (${KYC_MESSAGE_FIELDS[d.index]}) = "${Buffer.from(d.message, 'hex')}" ← disclosed by policy to both; a cohort value, not a per-user handle`)
147
+ }
148
+
149
+ line()
150
+ line('RESULT: both presentations verified; the pooled correlation surface contains only')
151
+ line('cohort-level constants and the claims each RP itself requested. No per-holder')
152
+ line('value appears in both envelopes.')
153
+ line()
154
+ line('HONESTY BOUNDARY — read before quoting this result:')
155
+ line(' This demonstrates unlinkability at the cryptographic PRESENTATION LAYER only.')
156
+ line(' It does NOT defend against network-level correlation (IP address, timing,')
157
+ line(' device fingerprints, cookies) or relying parties pooling out-of-band data')
158
+ line(' they collected themselves (email, phone). @solidus-network/bbs is testnet-grade;')
159
+ line(' external audit pending (NLnet NGI Zero, H2 2026 target).')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@solidus-network/auth",
3
- "version": "0.6.1",
4
- "description": "DID-based authentication for the Solidus Network protocol — Ed25519 challenge / W3C Verifiable Presentation verification primitives.",
3
+ "version": "0.6.2",
4
+ "description": "DID-based authentication and DID-less BBS+ selective-disclosure verification for the Solidus Network protocol — Ed25519 challenge/response, unlinkable presentation envelopes, and the relying-party issuer-key resolver.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -9,22 +9,54 @@
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts"
12
+ },
13
+ "./envelope": {
14
+ "import": "./dist/envelope.js",
15
+ "types": "./dist/envelope.d.ts"
16
+ },
17
+ "./pairwise": {
18
+ "import": "./dist/pairwise.js",
19
+ "types": "./dist/pairwise.d.ts"
12
20
  }
13
21
  },
14
- "files": ["dist", "README.md", "LICENSE"],
22
+ "files": [
23
+ "dist",
24
+ "demo",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
15
28
  "dependencies": {
29
+ "@solidus-network/types": "0.6.2",
30
+ "@solidus-network/bbs": "0.6.2",
16
31
  "@noble/ed25519": "^2.1.0",
17
- "@noble/hashes": "^1.5.0"
32
+ "@noble/hashes": "^1.5.0",
33
+ "bs58": "^6.0.0"
18
34
  },
19
- "keywords": ["solidus", "did", "authentication", "decentralized-identity", "ed25519", "verifiable-presentation", "challenge-response"],
35
+ "keywords": [
36
+ "solidus",
37
+ "did",
38
+ "authentication",
39
+ "decentralized-identity",
40
+ "ed25519",
41
+ "challenge-response",
42
+ "signature",
43
+ "bbs",
44
+ "selective-disclosure",
45
+ "unlinkability",
46
+ "verifiable-presentation"
47
+ ],
20
48
  "author": "Solidus Network (https://solidus.network)",
21
49
  "license": "Apache-2.0",
22
50
  "homepage": "https://solidus.network",
23
- "bugs": { "url": "https://github.com/solidusnetwork/sdk/issues" },
51
+ "bugs": {
52
+ "url": "https://github.com/solidusnetwork/sdk/issues"
53
+ },
24
54
  "repository": {
25
55
  "type": "git",
26
56
  "url": "git+https://github.com/solidusnetwork/sdk.git",
27
57
  "directory": "auth"
28
58
  },
29
- "publishConfig": { "access": "public" }
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
30
62
  }