Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,64 @@ your own callback.
> rejected, check that Minister's `MINISTER_ISSUER_DOMAIN` host matches its
> OIDC issuer host.

## Sybil gating with the per-RP nullifier

Some badges carry a **gating nullifier** — `verifiedBadge.nullifier`, an opaque
`mnv1:...` string typed as `MinisterGatingNullifier`. When present, it is a
per-relying-party Sybil-dedup tag Minister derives from the *credential* behind
the badge (the email, the GitHub account) and stamps inside the signed
`credentialSubject` at disclosure. Use it to enforce "one credential, one
account" on your side (dedup, persistent bans):

```ts
const badge = badges.find((b) => b.type === "oauth-account");
if (badge?.nullifier) {
// Stable per (credential, YOUR site). Ban or dedup keyed on this value.
await gateOn(badge.nullifier);
}
```

Its guarantees, precisely:

- **Per-site and unlinkable.** Your site sees a value derived for your
`client_id`; other sites get a *different, unlinkable* tag for the same
credential. It is not a cross-site correlator.
- **Stable and persistent.** The *same* value appears if any account re-proves
the same credential to your site — and it **survives the user deleting and
re-creating their Minister account**, so a ban keyed on it persists across that
churn.
- **Bound under the signature.** It rides in the same signed VC as the badge,
tied to this badge's subject, `jti`, type, and `exp`, so it cannot be lifted
onto another credential or replayed as another user. The existing binding rule
still holds: the wrappers (`verifyBadges` / `exchangeCode` /
`ministerBadgesFromProfile`) require the badge subject's trailing component to
equal the `id_token` `sub`, so a borrowed badge lands in `rejected`.
- **Absent ⇒ untagged.** Badges with no wired nullifier (invite-code,
age/residency, and any pre-M5 disclosure) expose `nullifier: undefined`;
gate accordingly. A present-but-malformed value fails the badge closed.

**Honesty: this proves one credential, not one person.** Its strength is only
the strength of the credential behind it — see each badge type's
`sybilResistance` (`none` / `weak` / `moderate`). A `weak` anchor (catch-all
email domains, cheap GitHub accounts) is cheap to farm; weight it yourself.
Never treat the nullifier as a unique-human oracle.

### Two nullifier primitives, permanently distinct

This value is **not** interchangeable with `@ministryofmany/nullifier`'s
Poseidon/BN254 nullifier, and there is no conversion between them:

| | `@ministryofmany/nullifier` | `MinisterGatingNullifier` (this) |
| --- | --- | --- |
| Math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 |
| Anchor | the per-RP `sub` (account) | the credential (email, GitHub id) |
| Circuit-usable | yes (SNARK-provable; membership/RLN) | no (gating-only, plaintext compare) |
| Catches | same account across contexts | same credential across accounts |

The branded type stops the two from being mixed at compile time. A future
circuit-usable *credential* nullifier must be a new Poseidon construction, never
a bridge from this gating value.

## API

| Export | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion dist/auth-js.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OIDCConfig } from '@auth/core/providers';
import { JWTPayload } from 'jose';
import { K as KeyInput, B as BadgesResult } from './types-B-nDX3ql.js';
import { K as KeyInput, B as BadgesResult } from './types-C8FYcOBP.js';

interface MinisterProviderOptions {
clientId: string;
Expand Down
2 changes: 1 addition & 1 deletion dist/auth-js.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion dist/chunk-AIB6IEP6.js.map

This file was deleted.

14 changes: 13 additions & 1 deletion dist/chunk-AIB6IEP6.js → dist/chunk-FTCBESCF.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dist/chunk-FTCBESCF.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { K as KeyInput, V as VerifiedBadge, E as ExchangeResult, P as PkcePair, M as MinisterClientConfig, a as MinisterClaims, B as BadgesResult } from './types-B-nDX3ql.js';
export { b as MinisterTokenError, O as OidcError, c as OidcFlowState, R as RejectedBadge, d as VcVerificationError } from './types-B-nDX3ql.js';
import { K as KeyInput, V as VerifiedBadge, E as ExchangeResult, P as PkcePair, M as MinisterClientConfig, a as MinisterClaims, B as BadgesResult } from './types-C8FYcOBP.js';
export { b as MinisterGatingNullifier, c as MinisterTokenError, O as OidcError, d as OidcFlowState, R as RejectedBadge, e as VcVerificationError } from './types-C8FYcOBP.js';
import { JWTPayload } from 'jose';
export { ACCOUNT_AGE_MONTHS, AGE_THRESHOLDS, AccountAgeClaims, AccountAgeMonths, AgeOverClaimsFor, AgeThreshold, BADGE_TYPES, BadgeTypeDef, EmailDomainClaims, EmailExactClaims, FOLLOWERS_BUCKETS, FollowersBucket, InviteCodeClaims, OAUTH_PROVIDERS, OAuthAccountClaims, ResidencyCityClaims, ResidencyCountryClaims, ResidencyStateClaims, SocialFollowingClaims, SybilResistance, TlsnAttestationClaims, badgeScope, badgeScopes, badgeTypeOf, defineBadgeType, getBadgeClaimSchema, knownBadgeTypes, slugForCredentialType } from './badges/index.js';
import 'zod';
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 30 additions & 1 deletion dist/types-B-nDX3ql.d.ts → dist/types-C8FYcOBP.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ interface MinisterClaims {
picture?: string;
raw: string;
}
/**
* Minister's per-relying-party Sybil-dedup nullifier (`mnv1:...`), the value
* Minister stamps in a disclosed badge's `credentialSubject.nullifier` (M5).
*
* BRANDED so it can NEVER be interchanged with the OTHER, unrelated nullifier
* primitive in this ecosystem — `@ministryofmany/nullifier`'s Poseidon/BN254
* field string (`poseidon2(toField(sub), contextId)`), which is account-anchored
* and SNARK-provable. These two are permanently distinct (M3):
*
* | | `@ministryofmany/nullifier` | this `MinisterGatingNullifier` |
* |---|---|---|
* | math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 |
* | anchor | the per-RP `sub` (account) | the credential (email, github id) |
* | circuit-usable | YES | NO (gating-only, plaintext compare) |
* | catches | same-account-across-contexts | same-credential-across-accounts |
*
* There is no conversion between them. A future circuit-usable credential
* nullifier must be a NEW Poseidon construction, never a bridge from this value.
*
* HONESTY: this proves ONE CREDENTIAL, not one person. It is per-site
* (different, unlinkable at other RPs), stable for the same credential (the same
* value if any account re-proves it here, surviving account delete/re-create),
* and only as strong as the credential behind it (see each badge type's
* `sybilResistance`). Gate on it; do not treat it as a unique-human oracle.
*/
type MinisterGatingNullifier = string & {
readonly __brand: "MinisterGatingNullifier";
};
/**
* A signature-verified, schema-validated badge.
*
Expand Down Expand Up @@ -62,6 +90,7 @@ interface VerifiedBadge {
claims: Record<string, unknown>;
subject: string;
issuanceMonth?: string;
nullifier?: MinisterGatingNullifier;
raw: string;
}
interface RejectedBadge {
Expand All @@ -79,4 +108,4 @@ interface ExchangeResult {
}
type KeyInput = KeyLike | JWK | Uint8Array | JWTVerifyGetKey;

export { type BadgesResult as B, type ExchangeResult as E, type KeyInput as K, type MinisterClientConfig as M, OidcError as O, type PkcePair as P, type RejectedBadge as R, type VerifiedBadge as V, type MinisterClaims as a, MinisterTokenError as b, type OidcFlowState as c, VcVerificationError as d };
export { type BadgesResult as B, type ExchangeResult as E, type KeyInput as K, type MinisterClientConfig as M, OidcError as O, type PkcePair as P, type RejectedBadge as R, type VerifiedBadge as V, type MinisterClaims as a, type MinisterGatingNullifier as b, MinisterTokenError as c, type OidcFlowState as d, VcVerificationError as e };
7 changes: 7 additions & 0 deletions packages/minister-verify/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ export function makeVerifier(deps: VerifierDeps) {
// The issuance-month bucket START (coarse, fail-closed) — never the
// disclosure-time iat. See issuedAtFromIssuanceMonth.
issuedAt: issuedAtFromIssuanceMonth(b.issuanceMonth),
// NOTE: `b.nullifier` (the per-RP `mnv1:` gating tag) is intentionally
// DROPPED here — this mapping is not wired for nullifier-based gating
// yet (the Discreetly gating follow-up is optional per the crypto-core
// ADR). It does NOT propagate to policy evaluation via `attributes`
// either: `b.claims` has the nullifier stripped SDK-side. When room
// gating on the nullifier lands, thread it through as an optional field
// here; until then, do NOT assume it reaches the policy layer.
})),
};
};
Expand Down
45 changes: 45 additions & 0 deletions packages/nullifier/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# @ministryofmany/nullifier

Poseidon/BN254 context nullifier — the **circuit-usable, account-anchored**
nullifier primitive for the Ministry ecosystem (Discreetly RLN/membership, the
Deforum user-sub-forum anchor). It derives a SNARK-friendly field element that is
stable per `(sub, contextId)` and unlinkable across contexts without the `sub`.

```ts
import { deriveContextNullifier } from "@ministryofmany/nullifier";

// poseidon2(toField(sub), contextId % FIELD)
const nul = deriveContextNullifier(pairwiseSub, roomRlnIdentifier);
```

`toField` reduces an arbitrary string to a BN254 field element by big-endian
base-256 accumulation mod `FIELD` (NOT a hash — it is the exact reduction
Discreetly's `toField` performs, preserved byte-for-byte). Use
`deriveContextNullifierFromField` when the first input is ALREADY a field element
(e.g. a membership proof's nullifier) rather than an arbitrary string.

## Two nullifier primitives — permanently distinct, never bridge

This ecosystem has **two** nullifiers. They are NOT interchangeable, there is no
conversion between them, and mixing them is a correctness bug:

| | `@ministryofmany/nullifier` (this package) | Minister gating nullifier (`MinisterGatingNullifier`, `mnv1:...`) |
|---|---|---|
| Math | Poseidon / BN254 (`poseidon2(toField(sub), contextId)`) | RFC 9497 VOPRF (stage 1) + HMAC-SHA256 (stage 2) |
| Anchor | the per-RP `sub` (account-anchored) | the credential (email, github id) |
| Circuit-usable | **YES** — SNARK-provable (RLN, membership) | **NO** — gating-only, plaintext compare |
| Catches | same-account-across-contexts linkage | same-credential-across-accounts Sybil |
| Wire form | decimal BN254 field string | `mnv1:` + base64url |

**Never bridge from `mnv1:`.** The gating nullifier is a plaintext gating tag; it
is not a field element and must never be fed into `toField`, `poseidon2`, or any
circuit input. Doing so would produce a valid-LOOKING field element that silently
conflates two unrelated anonymity namespaces. `toField` enforces this at runtime:
it **throws** on any input matching `^mnv1:`. A future circuit-usable *credential*
nullifier must be a NEW Poseidon construction over an appropriate anchor, not a
reduction of the gating tag.

The gating nullifier lives on `@minister/client`'s `VerifiedBadge.nullifier` and
is documented in `src/types.ts` (`MinisterGatingNullifier`). Gate on it for
"one credential" (Sybil dedup, ban persistence); do not treat it as a
unique-human oracle.
9 changes: 9 additions & 0 deletions packages/nullifier/src/derive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ describe("toField golden vectors", () => {
it("is field-bounded", () => {
expect(toField("any-long-string-".repeat(20))).toBeLessThan(FIELD);
});

it("REFUSES an mnv1 gating nullifier (M3 cross-primitive guard)", () => {
// The gating nullifier and this Poseidon primitive are non-interchangeable;
// feeding one into the other must fail loudly, not produce a field element.
expect(() => toField("mnv1:AbC-123_def")).toThrow(/mnv1/);
expect(() => deriveContextNullifier("mnv1:AbC-123_def", 700n)).toThrow(/mnv1/);
// A plain sub that merely CONTAINS the substring elsewhere is unaffected.
expect(() => toField("user-mnv1:not-a-prefix")).not.toThrow();
});
});

describe("deriveContextNullifier golden vectors", () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/nullifier/src/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export const FIELD = BigInt(
* `toField` would re-hash its digits and is not the identity on field elements.
*/
export function toField(s: string): bigint {
// Cross-primitive guard (M3). This Poseidon/BN254 nullifier is PERMANENTLY
// DISTINCT from Minister's gating nullifier (`mnv1:...`, HMAC/VOPRF,
// plaintext-compare, NOT circuit-usable). The two must never bridge. The
// TypeScript brand blocks the assignment one direction, but a branded value
// widens back to `string`, so `toField(badge.nullifier)` would type-check and
// silently produce a valid-looking field element. Reject it at runtime: a
// future circuit-usable credential nullifier must be a NEW construction, not a
// reduction of the gating tag.
if (/^mnv1:/.test(s)) {
throw new Error(
"toField refuses an `mnv1:` gating nullifier: it is not interchangeable with the " +
"Poseidon/BN254 primitive (M3). Derive a new nullifier, never bridge from mnv1.",
);
}
let acc = 0n;
for (const byte of new TextEncoder().encode(s)) acc = (acc * 256n + BigInt(byte)) % FIELD;
return acc;
Expand Down
Loading
Loading