signet-protocol 1.10.1 → 1.11.1

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.
Files changed (51) hide show
  1. package/README.md +15 -10
  2. package/dist/bot-ownership.d.ts +61 -0
  3. package/dist/bot-ownership.d.ts.map +1 -0
  4. package/dist/bot-ownership.js +88 -0
  5. package/dist/bot-ownership.js.map +1 -0
  6. package/dist/crypto.d.ts +2 -0
  7. package/dist/crypto.d.ts.map +1 -1
  8. package/dist/crypto.js +4 -0
  9. package/dist/crypto.js.map +1 -1
  10. package/dist/experimental.d.ts +19 -0
  11. package/dist/experimental.d.ts.map +1 -0
  12. package/dist/experimental.js +16 -0
  13. package/dist/experimental.js.map +1 -0
  14. package/dist/index.js +2 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/url-auth.d.ts.map +1 -1
  17. package/dist/url-auth.js +2 -1
  18. package/dist/url-auth.js.map +1 -1
  19. package/dist/utils.d.ts +46 -0
  20. package/dist/utils.d.ts.map +1 -1
  21. package/dist/utils.js +127 -0
  22. package/dist/utils.js.map +1 -1
  23. package/dist/vault-checkpoint.d.ts +50 -0
  24. package/dist/vault-checkpoint.d.ts.map +1 -0
  25. package/dist/vault-checkpoint.js +84 -0
  26. package/dist/vault-checkpoint.js.map +1 -0
  27. package/dist/vault-keys.d.ts +19 -0
  28. package/dist/vault-keys.d.ts.map +1 -0
  29. package/dist/vault-keys.js +57 -0
  30. package/dist/vault-keys.js.map +1 -0
  31. package/dist/vault-recovery.d.ts +99 -0
  32. package/dist/vault-recovery.d.ts.map +1 -0
  33. package/dist/vault-recovery.js +305 -0
  34. package/dist/vault-recovery.js.map +1 -0
  35. package/dist/vault-relay.d.ts +29 -0
  36. package/dist/vault-relay.d.ts.map +1 -0
  37. package/dist/vault-relay.js +131 -0
  38. package/dist/vault-relay.js.map +1 -0
  39. package/llms-full.txt +3744 -0
  40. package/llms.txt +134 -0
  41. package/package.json +15 -6
  42. package/src/bot-ownership.ts +77 -0
  43. package/src/crypto.ts +5 -0
  44. package/src/experimental.ts +25 -0
  45. package/src/index.ts +3 -0
  46. package/src/url-auth.ts +2 -1
  47. package/src/utils.ts +122 -0
  48. package/src/vault-checkpoint.ts +98 -0
  49. package/src/vault-keys.ts +59 -0
  50. package/src/vault-recovery.ts +284 -0
  51. package/src/vault-relay.ts +112 -0
package/llms.txt ADDED
@@ -0,0 +1,134 @@
1
+ # Signet Protocol
2
+
3
+ > Nostr has no identity layer: anyone can impersonate anyone, and there's no way to verify age for child safety. Signet is a decentralised identity verification protocol for Nostr that lets users prove claims about their identity (age, profession, parenthood) using zero-knowledge proofs, without revealing personal data or relying on a central authority.
4
+
5
+ Signet is for Nostr client developers, relay operators, and anyone building identity-aware applications on Nostr. It provides a TypeScript library (`signet-protocol`) that handles credentials, vouches, trust scoring, and ZKP age proofs. Unlike centralised identity services, Signet anchors trust to professional bodies (law societies, medical boards, notary commissions) and uses cryptographic proofs instead of data collection.
6
+
7
+ ## Getting Started
8
+
9
+ ```bash
10
+ npm install signet-protocol
11
+ ```
12
+
13
+ Requires Node ≥22. ESM only (no CommonJS `require` export).
14
+
15
+ Display a verification badge for any Nostr pubkey (Level 1 integration: a weekend):
16
+
17
+ ```typescript
18
+ import { computeBadge, buildBadgeFilters, fetchFromRelay, meetsMinimumTier } from 'signet-protocol';
19
+
20
+ // Build relay filters and fetch events (fetchFromRelay is the package's own minimal relay client)
21
+ const filters = buildBadgeFilters(['<hex-pubkey>']);
22
+ const events = await fetchFromRelay('wss://relay.example.com', filters);
23
+
24
+ // Compute badge (async: returns Promise<BadgeInfo>)
25
+ const badge = await computeBadge('<hex-pubkey>', events, { verifySignatures: true });
26
+ // badge.tier => 3, badge.score => 106, badge.displayLabel => "Verified (Tier 3)"
27
+
28
+ // Gate features on verification level
29
+ if (meetsMinimumTier(badge, 2)) {
30
+ // allow posting in verified-only community
31
+ }
32
+ ```
33
+
34
+ `fetchFromRelay(relayUrl: string, filters: NostrFilter[]): Promise<NostrEvent[]>` connects, collects events until EOSE and disconnects. Non-localhost relay URLs must be `wss://`. Any other Nostr client works equally well: pass the `buildBadgeFilters` output as REQ filters and give the returned events to `computeBadge`.
35
+
36
+ ## Key Concepts
37
+
38
+ - **4 verification tiers**: Self-declared (Tier 1) → Web-of-trust via peer vouches (Tier 2) → Professional verification with ZKP age proof (Tier 3) → Professional + child safety (Tier 4)
39
+ - **Signet IQ** (0–200): A continuous identity quality score measuring statistical confidence that an identity is real, not fabricated. Weighted by professional verification, in-person vouches, online vouches, account age, and identity bridges. 100 represents the current government standard. IQ is not reputation: it measures identity quality, not trustworthiness. The source calls the same number the "Signet Score" (`TrustScoreBreakdown.score`).
40
+ - **Zero-knowledge age proofs**: Pedersen range proofs on secp256k1 (the `@forgesworn/range-proof` dependency; no trusted setup) prove age ranges (e.g. "adult" or "child aged 8–12") without revealing date of birth.
41
+ - **Document nullifiers**: SHA-256 of document fields prevents duplicate identity registration without storing any personal data.
42
+ - **Single attestation kind** (31000, NIP-VA) plus policies (30078): All identity attestations (credentials, vouches, verifier management, challenges, revocations, identity bridges, delegation) share kind 31000, differentiated by `type` tag. The voting extension (kinds 30482–30484) is a separate package, `nostr-anon-vote`.
43
+
44
+ ## API Surface
45
+
46
+ Conventions used throughout:
47
+ - Private keys are 64-character hex strings (not `Uint8Array`). Public keys are 64-character x-only hex. The identity functions are the exception: `createSignetIdentity` takes a BIP-39 mnemonic and `createSignetIdentityFromNsec` takes a bech32 nsec string or 32 raw bytes.
48
+ - Of the `create*` functions documented in this file, the event-signing ones (credentials, vouches, policies, verifiers, challenges, revocations, identity bridges, guardian delegations) are async and return a signed `Promise<NostrEvent>` (with `id` and `sig`). The matching `build*Event` functions return an unsigned `UnsignedEvent`. The documented `create*` helpers that are not event signers return what is stated beside them: `createAgeRangeProof` (synchronous, `RangeProof`), `createSignetIdentity` / `createSignetIdentityFromNsec` (synchronous, `SignetIdentity`) and `createLinkageProof` (synchronous, `LinkageProof`). The package root exports other `create*` functions that this file does not cover (for example `createTwoCredentialCeremony`, `createBondProof`); check their return types in `src/` rather than assuming the event-signer shape.
49
+ - Tiers are `SignetTier` (`1 | 2 | 3 | 4`). Timestamps (`expiresAt`, `occurredAt`, `accountCreatedAt`) are unix seconds.
50
+ - Throws use the `SignetError` hierarchy (`SignetValidationError`, `SignetCryptoError`).
51
+
52
+ ### Credentials (kind 31000, type: credential)
53
+ - `createSelfDeclaredCredential(privateKey, scope = 'adult', expiresAt?)`: Tier 1. `scope` is `'adult' | 'adult+child'`.
54
+ - `createPeerVouchedCredential(issuerPrivateKey, subjectPubkey, opts)`: Tier 2. `opts: { assertionEventId: string; assertionRelay?: string; expiresAt?: number }`. `assertionEventId` is the subject's Tier 1 event.
55
+ - `createProfessionalCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 3. `opts: { assertionEventId: string; profession: string; jurisdiction: string; actualAge: number; assertionRelay?: string; expiresAt?: number; occurredAt?: number; proofBlob?: string }`. Generates an `'18+'` range proof from `actualAge` into the event content.
56
+ - `createChildSafetyCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 4. Same `opts` as Tier 3 plus `ageRange: string` (the range proved, e.g. `'8-12'`).
57
+ - `verifyCredential(event)`: `Promise<{ signatureValid: boolean; structureValid: boolean; expired: boolean; errors: string[] }>`
58
+ - `parseCredential(event)`: `ParsedCredential | null`
59
+
60
+ `ageRange` format everywhere: `'<min>-<max>'` inclusive (e.g. `'8-12'`, `'13-17'`) or `'<min>+'` (e.g. `'18+'`, upper bound 150). Anything else fails to parse.
61
+
62
+ Default credential expiry is two years (`DEFAULT_CREDENTIAL_EXPIRY_SECONDS`) when `expiresAt` is omitted.
63
+
64
+ ### Vouches (kind 31000, type: vouch)
65
+ - `createVouch(voucherPrivateKey, params)`: vouch for another user. `params: { subjectPubkey: string; method: 'in-person' | 'online'; voucherTier: SignetTier; voucherScore: number; context?: string; occurredAt?: number }`. `voucherScore` is the voucher's own Signet IQ (0–200).
66
+ - `hasEnoughVouches(vouches, subjectPubkey, threshold = 3, minVoucherTier = 2)`: `boolean`. `true` when the number of qualifying vouches (one per voucher, voucher tier ≥ `minVoucherTier`) reaches `threshold`. `countQualifyingVouches(vouches, subjectPubkey, minVoucherTier?)` returns the count.
67
+
68
+ ### Trust & Badges
69
+ - `computeBadge(pubkey, events, opts?)`: `Promise<BadgeInfo>`. `opts: { verifySignatures?: boolean (default false); now?: number }`. `BadgeInfo: { pubkey, tier, tierLabel, score, isVerified, displayLabel, credentialCount, vouchCount, entityType? }`.
70
+ - `buildBadgeFilters(pubkeys: string[])`: `Array<{ kinds: number[]; '#d': string[] }>`. One filter for kind 31000 with d-tags `credential:<pubkey>` and `vouch:<pubkey>` for each pubkey.
71
+ - `meetsMinimumTier(badge: BadgeInfo, minTier: SignetTier)`: `boolean`. `true` only when `badge.isVerified` and `badge.tier >= minTier`.
72
+ - `computeTrustScore(subjectPubkey, credentials, vouches, accountCreatedAt?, bridges?)`: `TrustScoreBreakdown` (`{ score, tier, professionalVerifications, inPersonVouches, onlineVouches, accountAgeDays, signals }`). This is the full Signet IQ. It does not verify signatures.
73
+
74
+ `badge.score` is on the same 0–200 scale and uses the same weights as `computeTrustScore(...).score` (professional verification 80, in-person vouch up to 16, online vouch up to 4, each vouch scaled by the voucher's score ÷ 200), but it is a Level 1 approximation, not the full IQ: `computeBadge` ignores account age and identity bridges and does not apply the per-type caps (1 professional, 3 in-person vouches, 5 online vouches). Use `computeTrustScore` when you need the full IQ.
75
+
76
+ ### Policies (kind 30078, NIP-78)
77
+ - `createPolicy(operatorPrivateKey, params)`: community verification requirements. `params: { communityId: string; adultMinTier: SignetTier; childMinTier: SignetTier; enforcement: 'client' | 'relay' | 'both'; minScore?: number; modMinTier?: SignetTier; verifierBond?: number; revocationThreshold?: number; description?: string }`. The d-tag is `signet:policy:<communityId>`.
78
+ - `new PolicyChecker(policyEvent: NostrEvent)`: throws `SignetValidationError('Invalid policy event')` if the event is not a valid policy. Methods: `getPolicy(): ParsedPolicy`, `checkAdult(tier, score)`, `checkChild(tier, score)`, `checkModerator(tier, score)`. Each check returns `PolicyCheckResult`: `{ allowed: boolean; reason?: string; requiredTier: SignetTier; actualTier: SignetTier; requiredScore?: number; actualScore?: number }`.
79
+ - `parsePolicy(event)`: `ParsedPolicy | null`. `checkPolicyCompliance(policy, userTier, userScore, { isChild?, isModerator? })`: `PolicyCheckResult`.
80
+
81
+ ### Verifiers (kind 31000, type: verifier)
82
+ - `createVerifierCredential(privateKey, params)`: register as professional verifier. `params: { profession: string; jurisdiction: string; licenceHash: string; professionalBody: string; statement?: string; bondProof?: BondProof }`.
83
+ - `checkCrossVerification(verifierPubkey, vouches, verifierCredentials)`: activation check. Returns `{ activated: boolean; vouchCount: number; professions: string[]; errors: string[] }`.
84
+ - `isVerifierRevoked(verifierPubkey, revocations)`: `boolean`
85
+
86
+ ### Challenges and Revocations (kind 31000, type: challenge / revocation)
87
+ - `createChallenge(reporterPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; reason: 'anomalous-volume' | 'registry-mismatch' | 'fraudulent-attestation' | 'licence-revoked' | 'other'; evidenceType: string; reporterTier: SignetTier; evidence: string; occurredAt?: number }`. `evidence` becomes the event content.
88
+ - `createRevocation(authorityPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; challengeEventId: string; confirmations: number; bondAction: 'slashed' | 'returned' | 'held'; scope: 'full' | 'partial'; effectiveAt: number; summary: string }`.
89
+ - `countChallengeConfirmations(challengeEventId, confirmationEvents, credentialEvents)`: `number`
90
+ - `hasReachedRevocationThreshold(confirmations, threshold = 5)`: `boolean`
91
+ - `parseChallenge(event)` / `parseRevocation(event)`: parsed object or `null`.
92
+
93
+ ### Identity Bridge (kind 31000, type: identity-bridge)
94
+ - `createIdentityBridge(anonPrivateKey, realPrivateKey, ring, signerIndex, ringMinTier, opts?)`: link anonymous account to verified identity via ring signature. `ring: string[]` of pubkeys (at least 5, `MIN_BRIDGE_RING_SIZE`); `opts: { occurredAt?: number }`. Signed by the anonymous key.
95
+ - `selectDecoyRing(verifiedPubkeys, realPubkey, ringSize = 5)`: `{ ring: string[]; signerIndex: number }`
96
+ - `verifyIdentityBridge(event, opts?)`: `Promise<boolean>`. `opts: { maxAgeSeconds?: number }` (default 24 hours; `0` disables the freshness check).
97
+
98
+ ### ZKP Age Proofs
99
+ - `createAgeRangeProof(age: number, ageRange: string, subjectPubkey?: string)`: `RangeProof` (synchronous). `subjectPubkey` binds the proof to one credential.
100
+ - `verifyAgeRangeProof(proof, expectedAgeRange, expectedSubjectPubkey?)`: `boolean`. Verify against the age policy you expect, not the range the proof claims.
101
+ - `serializeRangeProof(proof): string` / `deserializeRangeProof(json: string): RangeProof`
102
+
103
+ ### Identity & Personas
104
+ - `createSignetIdentity(mnemonic, passphrase?)` / `createSignetIdentityFromNsec(nsec: string | Uint8Array)`: derive a master identity. Both return `SignetIdentity` (`{ root, naturalPerson, persona, mnemonic? }`) with natural-person and persona sub-identities.
105
+ - `deriveAdditionalPersona(root, name, index = 0)`: further `Persona`. `deriveDependantIdentity(root, dependantIndex)`: `DependantIdentity` (`{ derivationPath, naturalPerson, persona }`) for a child.
106
+ - `createLinkageProof(root, child, type: 'blind' | 'full')` / `verifyLinkageProof(proof)` (returns `boolean`): prove or verify that a child identity belongs to a root.
107
+ - `destroyIdentity(identity)`: zeroes the root and persona keys.
108
+ - nsec-tree's own `fromMnemonic` / `fromNsec` are not re-exported; this package wraps them as the two `createSignetIdentity*` functions. `derive` and `zeroise` are re-exported.
109
+
110
+ ### Guardian Delegation
111
+ - `createGuardianDelegation(guardianPrivateKey, params)`: kind 31000 (type: delegation) event scoping a guardian's authority over a dependant. `params: { childPubkey: string; delegatePubkey: string; scope: 'full' | 'activity-approval' | 'content-management' | 'contact-approval'; expiresAt?: number; occurredAt?: number }`.
112
+
113
+ ### signet-protocol/experimental (unstable)
114
+
115
+ Import from `signet-protocol/experimental`. Nothing here is exported from the package root. These are unreleased draft contracts under protocol review: any export may change or be removed in any release, including a minor or patch, with no deprecation period. Do not depend on them for production data.
116
+
117
+ - Private vault keys (see `docs/private-vault-v1.md`): `vaultPurpose`, `parseVaultPurpose`, `vaultKeyContext`, `deriveVaultIdentity`, `vaultIdentityFromMnemonic`; type `VaultDataset`.
118
+ - Private vault checkpoints: `VAULT_EVENT_KIND`, `MAX_VAULT_CHUNKS`, `MAX_VAULT_DEVICES`, `MAX_VAULT_CONTROL_BYTES`, `MAX_VAULT_CHUNK_BYTES`, `vaultCheckpointTag`, `vaultContentHash`, `parseVaultCheckpoint`, `matchesVaultChunk`, `vaultCheckpointShapedTag`; types `VaultCheckpoint`, `VaultChunkRef`.
119
+ - Private vault recovery: `readVaultSnapshot`, `readVaultRotations`, `readVaultHeads`, `readVaultHeadRotations`, `VAULT_CLOCK_TOLERANCE_SECONDS`; types `VaultReadResult`, `VaultReader`, `VaultHeadsResult`.
120
+ - Private vault relay reads: `createVaultRelayReader`, `fetchVaultEvents`, `MAX_VAULT_EVENTS_PER_RELAY`, `VaultRelayError`.
121
+ - Bot ownership profile (see `docs/bot-ownership-v1.md`): `BOT_OWNERSHIP_TYPE`, `BOT_OWNERSHIP_POLICY`, `buildBotOwnership`, `buildBotOwnershipRevocation`, `readBotOwnership`, `readBotOwnershipSync`, `botOwnershipRenewalDue`; types `BotOwnershipClaim`, `BotOwnershipResult`.
122
+
123
+ The voting extension (kinds 30482–30484) is specified in `spec/voting.md` and implemented in the separate `nostr-anon-vote` package on npm, not in `signet-protocol`.
124
+
125
+ ## Optional
126
+
127
+ - [llms-full.txt](llms-full.txt): llms.txt plus the full protocol and voting specifications and the developer guides in one file
128
+ - [Full protocol specification](spec/protocol.md)
129
+ - [Voting extension specification](spec/voting.md)
130
+ - [Signet in 5 Minutes: developer overview](docs/signet-in-5-minutes.md)
131
+ - [Implementation levels guide](docs/implementation-levels.md)
132
+ - [Full flow example](examples/full-flow.ts)
133
+ - [Jurisdiction-specific examples](examples/jurisdictions/)
134
+ - [Regulatory compatibility](README.md#regulatory-compatibility)
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "signet-protocol",
3
- "version": "1.10.1",
3
+ "version": "1.11.1",
4
4
  "description": "Decentralised identity verification protocol for Nostr",
5
+ "homepage": "https://github.com/forgesworn/signet#readme",
5
6
  "type": "module",
6
7
  "main": "./dist/index.js",
7
8
  "types": "./dist/index.d.ts",
@@ -9,11 +10,17 @@
9
10
  ".": {
10
11
  "import": "./dist/index.js",
11
12
  "types": "./dist/index.d.ts"
13
+ },
14
+ "./experimental": {
15
+ "import": "./dist/experimental.js",
16
+ "types": "./dist/experimental.d.ts"
12
17
  }
13
18
  },
14
19
  "files": [
15
20
  "dist",
16
- "src"
21
+ "src",
22
+ "llms.txt",
23
+ "llms-full.txt"
17
24
  ],
18
25
  "publishConfig": {
19
26
  "provenance": true
@@ -25,7 +32,9 @@
25
32
  "typecheck": "tsc --noEmit",
26
33
  "clean": "rm -rf dist",
27
34
  "prepare": "npm run build",
28
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
35
+ "build:llms": "node scripts/build-llms-full.mjs",
36
+ "check:llms": "node scripts/build-llms-full.mjs --check",
37
+ "prepublishOnly": "npm run build:llms && npm run typecheck && npm test && npm run build"
29
38
  },
30
39
  "keywords": [
31
40
  "nostr",
@@ -55,20 +64,20 @@
55
64
  "dependencies": {
56
65
  "@forgesworn/range-proof": "^2.0.0",
57
66
  "@forgesworn/ring-sig": "^3.0.0",
58
- "@forgesworn/shamir-words": "^1.0.0",
67
+ "@forgesworn/shamir-words": "^1.2.0-alpha.1",
59
68
  "@noble/curves": "^2.0.1",
60
69
  "@noble/hashes": "^2.0.1",
61
70
  "@scure/bip39": "^2.0.1",
62
71
  "jurisdiction-kit": "^1.0.0",
63
72
  "nostr-attestations": "^2.3.1",
64
- "nsec-tree": "^1.4.1",
73
+ "nsec-tree": "^1.6.0-alpha.1",
65
74
  "spoken-token": "^2.0.3"
66
75
  },
67
76
  "devDependencies": {
68
77
  "@types/node": "^25.3.3",
69
78
  "tsx": "^4.21.0",
70
79
  "typescript": "^6.0.2",
71
- "vitest": "^4.1.4"
80
+ "vitest": "^5.0.0"
72
81
  },
73
82
  "funding": {
74
83
  "type": "lightning",
@@ -0,0 +1,77 @@
1
+ /** Draft bot ownership profile of nostr-attestations. Builders never publish. */
2
+ import { createAttestation, createRevocation, buildDTag } from 'nostr-attestations';
3
+ import { verifyEventSync } from './crypto.js';
4
+ import { isSafeLabel } from './utils.js';
5
+ import type { NostrEvent, UnsignedEvent } from './types.js';
6
+ export const BOT_OWNERSHIP_TYPE = 'bot-ownership';
7
+ export const BOT_OWNERSHIP_POLICY = Object.freeze({ defaultDays: 30, minDays: 1, maxDays: 90,
8
+ renewBeforeDays: 10, retrySeconds: 86400, clockToleranceSeconds: 300 });
9
+ const DAY = 86400, HEX = /^[0-9a-f]{64}$/;
10
+ const stamp = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
11
+ const labelOk = (label: unknown): label is string => typeof label === 'string' && !!label.trim() && label.length <= 100
12
+ && isSafeLabel(label);
13
+ function validateParties(ownerPubkey: string, botPubkey: string, now: number) {
14
+ if (!HEX.test(ownerPubkey) || !HEX.test(botPubkey) || ownerPubkey === botPubkey || !stamp(now)) throw new Error('Invalid bot ownership parties or time');
15
+ }
16
+ /** The caller selects a persona, obtains explicit creation consent, and signs
17
+ * this ordinary Nostr event through its existing local or NIP-46 backend. */
18
+ export function buildBotOwnership(args: { ownerPubkey: string; botPubkey: string; label: string; now: number; expiresAt?: number }): UnsignedEvent {
19
+ validateParties(args.ownerPubkey, args.botPubkey, args.now);
20
+ const expiresAt = args.expiresAt ?? args.now + BOT_OWNERSHIP_POLICY.defaultDays * DAY;
21
+ if (!labelOk(args.label) || !stamp(expiresAt) || expiresAt < args.now + DAY || expiresAt > args.now + 90 * DAY) throw new Error('Bot ownership requires a label and a lifetime from one to ninety days');
22
+ return { ...createAttestation({ type: BOT_OWNERSHIP_TYPE, subject: args.botPubkey,
23
+ validFrom: args.now, validTo: expiresAt, expiration: expiresAt,
24
+ content: JSON.stringify({ v: 1, label: args.label.trim() }) }), pubkey: args.ownerPubkey, created_at: args.now };
25
+ }
26
+ export function buildBotOwnershipRevocation(args: { ownerPubkey: string; botPubkey: string; now: number }): UnsignedEvent {
27
+ validateParties(args.ownerPubkey, args.botPubkey, args.now);
28
+ return { ...createRevocation({ type: BOT_OWNERSHIP_TYPE, identifier: args.botPubkey, subject: args.botPubkey,
29
+ effective: args.now }), pubkey: args.ownerPubkey, created_at: args.now };
30
+ }
31
+ export interface BotOwnershipClaim { ownerPubkey: string; botPubkey: string; label: string; issuedAt: number; expiresAt: number; eventId: string }
32
+ export type BotOwnershipResult = { status: 'invalid' } | { status: 'revoked'; ownerPubkey: string; botPubkey: string; eventId: string }
33
+ | { status: 'valid' | 'lapsed'; claim: BotOwnershipClaim };
34
+ /** No ownership trust is inherited by the bot. Callers pin the expected persona
35
+ * and bot and separately establish that they selected the newest event. */
36
+ export async function readBotOwnership(event: NostrEvent, expected: { ownerPubkey: string; botPubkey: string; now: number; maxLifetimeDays?: number }): Promise<BotOwnershipResult> {
37
+ return readBotOwnershipSync(event, expected);
38
+ }
39
+ /** Same validation for synchronous transport/storage codecs. */
40
+ export function readBotOwnershipSync(event: NostrEvent, expected: { ownerPubkey: string; botPubkey: string; now: number; maxLifetimeDays?: number }): BotOwnershipResult {
41
+ const invalid = { status: 'invalid' } as const;
42
+ const maximum = expected.maxLifetimeDays ?? BOT_OWNERSHIP_POLICY.maxDays;
43
+ if (!stamp(expected.now) || !Number.isSafeInteger(maximum) || maximum < 1 || maximum > 90
44
+ || !event || event.kind !== 31000 || event.pubkey !== expected.ownerPubkey || !HEX.test(expected.ownerPubkey)
45
+ || !HEX.test(expected.botPubkey) || expected.ownerPubkey === expected.botPubkey
46
+ || !stamp(event.created_at) || event.created_at > expected.now + BOT_OWNERSHIP_POLICY.clockToleranceSeconds
47
+ || typeof event.content !== 'string' || event.content.length > 1000 || !Array.isArray(event.tags) || event.tags.length > 16
48
+ || !event.tags.every(tag => Array.isArray(tag) && tag.length >= 2 && tag.length <= 4 && tag.every(value => typeof value === 'string' && value.length <= 256))) return invalid;
49
+ const single = (name: string) => {
50
+ const values = event.tags.filter(tag => tag[0] === name);
51
+ return values.length === 1 ? values[0][1] : undefined;
52
+ };
53
+ if (single('d') !== buildDTag(BOT_OWNERSHIP_TYPE, expected.botPubkey) || single('type') !== BOT_OWNERSHIP_TYPE
54
+ || single('p') !== expected.botPubkey || !verifyEventSync(event)) return invalid;
55
+ const statuses = event.tags.filter(tag => tag[0] === 'status');
56
+ if (statuses.length) {
57
+ if (statuses.length !== 1 || statuses[0][1] !== 'revoked' || event.content !== '') return invalid;
58
+ return { status: 'revoked', ownerPubkey: event.pubkey, botPubkey: expected.botPubkey, eventId: event.id };
59
+ }
60
+ const start = single('valid_from'), end = single('valid_to'), expiration = single('expiration');
61
+ if (start !== String(event.created_at) || !end || !/^\d+$/.test(end) || end !== expiration) return invalid;
62
+ const declared = Number(end);
63
+ if (!stamp(declared) || declared < event.created_at + DAY) return invalid;
64
+ let body: { v?: unknown; label?: unknown };
65
+ try { body = JSON.parse(event.content); } catch { return invalid; }
66
+ if (!body || body.v !== 1 || !labelOk(body.label)) return invalid;
67
+ // A user may choose a stricter ceiling. It never extends the signed expiry.
68
+ const expiresAt = Math.min(declared, event.created_at + maximum * DAY, event.created_at + 90 * DAY);
69
+ return { status: expected.now > expiresAt + BOT_OWNERSHIP_POLICY.clockToleranceSeconds ? 'lapsed' : 'valid',
70
+ claim: { ownerPubkey: event.pubkey, botPubkey: expected.botPubkey, label: body.label.trim(), issuedAt: event.created_at, expiresAt, eventId: event.id } };
71
+ }
72
+ /** Scheduling only: hardware signing still needs its normal approval. */
73
+ export function botOwnershipRenewalDue(result: BotOwnershipResult, now: number, lastAttemptAt?: number): boolean {
74
+ if (!stamp(now) || (lastAttemptAt !== undefined && (!stamp(lastAttemptAt) || now - lastAttemptAt < BOT_OWNERSHIP_POLICY.retrySeconds))) return false;
75
+ return (result.status === 'valid' || result.status === 'lapsed') && now >= result.claim.issuedAt
76
+ && result.claim.expiresAt - now <= BOT_OWNERSHIP_POLICY.renewBeforeDays * DAY;
77
+ }
package/src/crypto.ts CHANGED
@@ -65,6 +65,11 @@ export async function signEvent(
65
65
 
66
66
  /** Verify a signed Nostr event's signature */
67
67
  export async function verifyEvent(event: NostrEvent): Promise<boolean> {
68
+ return verifyEventSync(event);
69
+ }
70
+
71
+ /** Synchronous verification for codecs that must reject invalid events inline. */
72
+ export function verifyEventSync(event: NostrEvent): boolean {
68
73
  try {
69
74
  const expectedId = getEventId(event);
70
75
  if (expectedId !== event.id) return false;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * signet-protocol/experimental — UNSTABLE, UNRELEASED draft contracts.
3
+ *
4
+ * The private vault checkpoint (docs/private-vault-v1.md) and the bot ownership
5
+ * profile (docs/bot-ownership-v1.md) are still under protocol review. Anything
6
+ * exported here may change or be removed in any release, including a minor or
7
+ * patch, without a deprecation period. Do not depend on it for production data.
8
+ * None of these names are exported from the package root.
9
+ */
10
+ // Private vault: dedicated dataset keys (flat derivation; index means rotation).
11
+ export { vaultPurpose, parseVaultPurpose, vaultKeyContext, deriveVaultIdentity, vaultIdentityFromMnemonic } from './vault-keys.js'
12
+ export type { VaultDataset } from './vault-keys.js'
13
+ export { VAULT_EVENT_KIND, MAX_VAULT_CHUNKS, MAX_VAULT_DEVICES, MAX_VAULT_CONTROL_BYTES,
14
+ MAX_VAULT_CHUNK_BYTES, vaultCheckpointTag, vaultContentHash, parseVaultCheckpoint,
15
+ matchesVaultChunk, vaultCheckpointShapedTag } from './vault-checkpoint.js'
16
+ export type { VaultCheckpoint, VaultChunkRef } from './vault-checkpoint.js'
17
+ export { readVaultSnapshot, readVaultRotations, readVaultHeads, readVaultHeadRotations,
18
+ VAULT_CLOCK_TOLERANCE_SECONDS } from './vault-recovery.js'
19
+ export type { VaultReadResult, VaultReader, VaultHeadsResult } from './vault-recovery.js'
20
+
21
+ export { createVaultRelayReader, fetchVaultEvents, MAX_VAULT_EVENTS_PER_RELAY, VaultRelayError } from './vault-relay.js'
22
+
23
+ export { BOT_OWNERSHIP_TYPE, BOT_OWNERSHIP_POLICY, buildBotOwnership, buildBotOwnershipRevocation,
24
+ readBotOwnership, readBotOwnershipSync, botOwnershipRenewalDue } from './bot-ownership.js';
25
+ export type { BotOwnershipClaim, BotOwnershipResult } from './bot-ownership.js';
package/src/index.ts CHANGED
@@ -518,3 +518,6 @@ export {
518
518
  parseMigrationEvent,
519
519
  followMigrationChain,
520
520
  } from './migration.js';
521
+
522
+ // Private vault and bot-ownership contracts are unreleased drafts: import them
523
+ // from 'signet-protocol/experimental'. Nothing from them is exported here.
package/src/url-auth.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { LoginRequest } from './qr-router.js';
10
+ import { UNSAFE_TEXT_CHARS } from './utils.js';
10
11
 
11
12
  /** Check if a URL is https:// or http://localhost (dev). */
12
13
  function isValidAuthUrl(url: string): boolean {
@@ -152,5 +153,5 @@ export function buildAuthDeniedUrl(callbackUrl: string): string {
152
153
  export function getUrlAuthSiteName(search: string): string {
153
154
  const params = new URLSearchParams(search);
154
155
  const name = params.get('name') ?? '';
155
- return name.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g, '').slice(0, 64);
156
+ return name.replace(new RegExp(UNSAFE_TEXT_CHARS.source, 'gu'), '').slice(0, 64);
156
157
  }
package/src/utils.ts CHANGED
@@ -18,3 +18,125 @@ export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
18
18
  }
19
19
  return diff === 0;
20
20
  }
21
+
22
+ /**
23
+ * One character class for text that is shown to people: C0 controls, DEL, C1
24
+ * controls, Arabic letter mark (U+061C), zero-width and LRM/RLM (U+200B-U+200F),
25
+ * line/paragraph separators and bidi embeddings/overrides (U+2028-U+202E), and
26
+ * bidi isolates (U+2066-U+2069). Not global: build a `g` copy to strip.
27
+ */
28
+ export const UNSAFE_TEXT_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2066-\u2069]/u;
29
+
30
+ /**
31
+ * `UNSAFE_TEXT_CHARS` minus U+200D (zero-width joiner), which is not a bidi
32
+ * control and is needed for emoji sequences, plus other invisible/deprecated-
33
+ * format characters a label has no legitimate use for: soft hyphen (U+00AD),
34
+ * Mongolian vowel separator (U+180E), word joiner and the deprecated invisible
35
+ * math operators (U+2060-U+2064), and the BOM / zero width no-break space
36
+ * (U+FEFF). This is a fixed denylist only; it does not cover every Unicode
37
+ * "format" (Cf) or Default_Ignorable code point (variation selectors, emoji
38
+ * tag characters, Hangul fillers, Arabic number-sign marks, deprecated music
39
+ * format controls, ...). Use `isSafeLabel` to validate labels \u2014 it checks
40
+ * this denylist plus those two Unicode categories, carving out only the
41
+ * narrow, positionally-constrained exceptions emoji sequences require.
42
+ */
43
+ export const UNSAFE_LABEL_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b\u200c\u200e\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/u;
44
+
45
+ const CF_OR_DEFAULT_IGNORABLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
46
+ const EXTENDED_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
47
+ const KEYCAP_BASE = /[#*0-9]/;
48
+ // A single unpaired UTF-16 surrogate is not a valid code point. In `u`-mode a
49
+ // correctly paired surrogate combines into one astral code point first and
50
+ // never matches this class; only a lone (unpaired) half does.
51
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
52
+ const ZWJ = 0x200d;
53
+ const VS15 = 0xfe0e, VS16 = 0xfe0f;
54
+ const SKIN_TONE_MIN = 0x1f3fb, SKIN_TONE_MAX = 0x1f3ff;
55
+ const TAG_CHAR_MIN = 0xe0020, TAG_CHAR_MAX = 0xe007e, TAG_CANCEL = 0xe007f, FLAG_BASE = 0x1f3f4;
56
+ // RGI emoji tag sequences: only these three UK subdivision flags are
57
+ // well-formed. Each entry is the tag-character run (base and cancel tag
58
+ // excluded) spelling the ISO 3166-2 subdivision code in lowercase ASCII,
59
+ // shifted into the U+E0000 tag-character plane.
60
+ const RGI_FLAG_TAG_SEQUENCES: readonly (readonly number[])[] = ['gbeng', 'gbsct', 'gbwls'].map(code =>
61
+ Array.from(code, ch => 0xe0000 + (ch.codePointAt(0) as number)));
62
+
63
+ /**
64
+ * Whether `codePoints[index]` is "an emoji element" for the zero-width-joiner
65
+ * rule below: an Extended_Pictographic code point itself, or U+FE0F / an
66
+ * emoji skin-tone modifier (U+1F3FB-1F3FF) that directly follows one.
67
+ */
68
+ function endsInEmojiElement(codePoints: string[], index: number): boolean {
69
+ if (index < 0) return false;
70
+ const ch = codePoints[index];
71
+ if (EXTENDED_PICTOGRAPHIC.test(ch)) return true;
72
+ const cp = ch.codePointAt(0) as number;
73
+ if (cp !== VS16 && !(cp >= SKIN_TONE_MIN && cp <= SKIN_TONE_MAX)) return false;
74
+ const prev = codePoints[index - 1];
75
+ return prev !== undefined && EXTENDED_PICTOGRAPHIC.test(prev);
76
+ }
77
+
78
+ /**
79
+ * A label is unsafe if it contains a lone (unpaired) UTF-16 surrogate, any
80
+ * `UNSAFE_LABEL_CHARS` character, or any Unicode format (Cf) or
81
+ * Default_Ignorable_Code_Point code point, EXCEPT the narrow in-context uses
82
+ * emoji sequences need:
83
+ *
84
+ * 1. U+200D (ZWJ) only between two emoji elements: the code point before it
85
+ * ends in an emoji element (see `endsInEmojiElement`) and the code point
86
+ * after it is Extended_Pictographic. A leading, trailing or doubled ZWJ,
87
+ * or one between ordinary letters, is rejected.
88
+ * 2. U+FE0E / U+FE0F (text/emoji variation selectors) only directly after
89
+ * an Extended_Pictographic code point or a keycap base ([#*0-9]). Every
90
+ * other variation selector (U+FE00-FE0D, U+E0100-E01EF) is rejected
91
+ * everywhere.
92
+ * 3. Tag characters U+E0020-E007E only inside one of the three RGI UK
93
+ * subdivision emoji flag sequences: U+1F3F4 (waving black flag) followed
94
+ * by the exact tag-character spelling of "gbeng", "gbsct" or "gbwls"
95
+ * then the cancel tag U+E007F, with nothing else in between. Any other
96
+ * tag run — a different or misspelled subdivision code, extra or missing
97
+ * tag characters, a hidden payload, a missing/extra cancel tag, or tag
98
+ * characters with no U+1F3F4 base — is rejected, as is U+E0001 anywhere.
99
+ *
100
+ * Iterates by code point, not UTF-16 code unit.
101
+ */
102
+ export function isSafeLabel(label: string): boolean {
103
+ if (LONE_SURROGATE.test(label) || UNSAFE_LABEL_CHARS.test(label)) return false;
104
+ const codePoints = Array.from(label);
105
+ // Indices that are part of one of the three well-formed RGI subdivision
106
+ // flag tag sequences: the exact tag-character spelling plus its cancel tag,
107
+ // immediately following a U+1F3F4.
108
+ const reservedTagIndex = new Set<number>();
109
+ for (let i = 0; i < codePoints.length; i++) {
110
+ if (codePoints[i].codePointAt(0) !== FLAG_BASE) continue;
111
+ for (const seq of RGI_FLAG_TAG_SEQUENCES) {
112
+ const cancelIndex = i + 1 + seq.length;
113
+ if (cancelIndex >= codePoints.length) continue;
114
+ const matchesSpelling = seq.every((cp, k) => codePoints[i + 1 + k].codePointAt(0) === cp);
115
+ if (matchesSpelling && codePoints[cancelIndex].codePointAt(0) === TAG_CANCEL) {
116
+ for (let k = i + 1; k <= cancelIndex; k++) reservedTagIndex.add(k);
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ for (let i = 0; i < codePoints.length; i++) {
122
+ const ch = codePoints[i];
123
+ if (!CF_OR_DEFAULT_IGNORABLE.test(ch)) continue;
124
+ const cp = ch.codePointAt(0) as number;
125
+ if (cp === ZWJ) {
126
+ const next = codePoints[i + 1];
127
+ if (endsInEmojiElement(codePoints, i - 1) && next !== undefined && EXTENDED_PICTOGRAPHIC.test(next)) continue;
128
+ return false;
129
+ }
130
+ if (cp === VS15 || cp === VS16) {
131
+ const prev = codePoints[i - 1];
132
+ if (prev !== undefined && (EXTENDED_PICTOGRAPHIC.test(prev) || KEYCAP_BASE.test(prev))) continue;
133
+ return false;
134
+ }
135
+ if ((cp >= TAG_CHAR_MIN && cp <= TAG_CHAR_MAX) || cp === TAG_CANCEL) {
136
+ if (reservedTagIndex.has(i)) continue;
137
+ return false;
138
+ }
139
+ return false;
140
+ }
141
+ return true;
142
+ }
@@ -0,0 +1,98 @@
1
+ /** Shared private-vault checkpoint contract. No relay I/O or application data schema. */
2
+ import { hmac } from '@noble/hashes/hmac.js'
3
+ import { sha256 } from '@noble/hashes/sha2.js'
4
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
5
+ import { parseVaultPurpose } from './vault-keys.js'
6
+
7
+ export const VAULT_EVENT_KIND = 30078
8
+ export const MAX_VAULT_CHUNKS = 32
9
+ export const MAX_VAULT_DEVICES = 16
10
+ export const MAX_VAULT_CONTROL_BYTES = 12 * 1024
11
+ export const MAX_VAULT_CHUNK_BYTES = 100_000
12
+ const HEX = /^[0-9a-f]{64}$/
13
+ const encoder = new TextEncoder()
14
+ const uint = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
15
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
16
+
17
+ export interface VaultChunkRef {
18
+ /** Exact signed event ID, never a mutable latest-by-tag lookup. */
19
+ eventId: string
20
+ author: string
21
+ /** SHA-256 of the UTF-8 encrypted event content. */
22
+ contentHash: string
23
+ contentBytes: number
24
+ }
25
+
26
+ export interface VaultCheckpoint {
27
+ v: 1
28
+ purpose: string
29
+ rotation: number
30
+ sequence: number
31
+ /** SHA-256 of the complete plaintext, checked before accepting a restored copy. */
32
+ revision: string
33
+ /** Device whose independently replaceable checkpoint this is. */
34
+ publisher?: string
35
+ devicePubkeys: string[]
36
+ /** Ordered chunk references; empty datasets still have one encrypted chunk. */
37
+ chunks: VaultChunkRef[]
38
+ /** Optional pointer, exactly rotation + 1; recovery follows it from index zero. */
39
+ nextRotation?: number
40
+ }
41
+
42
+ /** Publicly computable opaque tag; confidentiality comes from encryption and private keys. */
43
+ export function vaultCheckpointTag(author: string, publisher?: string): string {
44
+ if (!HEX.test(author)) throw new Error('Invalid vault author')
45
+ if (publisher !== undefined && !HEX.test(publisher)) throw new Error('Invalid vault publisher')
46
+ return bytesToHex(hmac(sha256, hexToBytes(author), encoder.encode(`signet-vault-route\u0000checkpoint\u0000${publisher ?? '0'}`))).slice(0, 32)
47
+ }
48
+
49
+ const CHECKPOINT_TAG = /^[0-9a-f]{32}$/
50
+
51
+ /** The single d-tag of a checkpoint-shaped event, or null. Every checkpoint tag
52
+ * is `vaultCheckpointTag` output: 32 lowercase hex characters. Necessary, not
53
+ * sufficient: the publisher is encrypted, so the shape cannot prove the tag. */
54
+ export function vaultCheckpointShapedTag(event: { tags?: unknown }): string | null {
55
+ if (!event || !Array.isArray(event.tags)) return null
56
+ const dTags = event.tags.filter((t): t is unknown[] => Array.isArray(t) && t[0] === 'd')
57
+ if (dTags.length !== 1 || dTags[0].length !== 2 || typeof dTags[0][1] !== 'string' || !CHECKPOINT_TAG.test(dTags[0][1])) return null
58
+ return dTags[0][1]
59
+ }
60
+
61
+ export function vaultContentHash(content: string): string {
62
+ return bytesToHex(sha256(encoder.encode(content)))
63
+ }
64
+
65
+ /** Accept no partial checkpoint. A bad reference must never silently lose data. */
66
+ export function parseVaultCheckpoint(raw: string, expected: { purpose: string; rotation: number }): VaultCheckpoint | null {
67
+ if (typeof raw !== 'string' || encoder.encode(raw).length > MAX_VAULT_CONTROL_BYTES) return null
68
+ let v: unknown
69
+ try { v = JSON.parse(raw) } catch { return null }
70
+ if (!object(v) || v.v !== 1 || typeof v.purpose !== 'string' || !parseVaultPurpose(v.purpose)
71
+ || v.purpose !== expected.purpose || v.rotation !== expected.rotation || !uint(v.rotation) || v.rotation > 0xffff_ffff
72
+ || !uint(v.sequence) || typeof v.revision !== 'string' || !HEX.test(v.revision)) return null
73
+ if (!Array.isArray(v.devicePubkeys) || !v.devicePubkeys.length || v.devicePubkeys.length > MAX_VAULT_DEVICES
74
+ || v.devicePubkeys.some(p => typeof p !== 'string' || !HEX.test(p))
75
+ || new Set(v.devicePubkeys).size !== v.devicePubkeys.length) return null
76
+ if (v.publisher !== undefined && (typeof v.publisher !== 'string' || !v.devicePubkeys.includes(v.publisher))) return null
77
+ if (!Array.isArray(v.chunks) || !v.chunks.length || v.chunks.length > MAX_VAULT_CHUNKS) return null
78
+ const chunks: VaultChunkRef[] = []
79
+ for (const c of v.chunks) {
80
+ if (!object(c) || typeof c.eventId !== 'string' || !HEX.test(c.eventId)
81
+ || typeof c.author !== 'string' || !v.devicePubkeys.includes(c.author)
82
+ || typeof c.contentHash !== 'string' || !HEX.test(c.contentHash)
83
+ || !uint(c.contentBytes) || c.contentBytes === 0 || c.contentBytes > MAX_VAULT_CHUNK_BYTES) return null
84
+ chunks.push({ eventId: c.eventId, author: c.author, contentHash: c.contentHash, contentBytes: c.contentBytes })
85
+ }
86
+ if (new Set(chunks.map(c => c.eventId)).size !== chunks.length) return null
87
+ if (v.nextRotation !== undefined && (v.nextRotation !== v.rotation + 1 || v.nextRotation > 0xffff_ffff)) return null
88
+ return { v: 1, purpose: v.purpose, rotation: v.rotation, sequence: v.sequence,
89
+ revision: v.revision, ...(v.publisher === undefined ? {} : { publisher: v.publisher as string }), devicePubkeys: [...v.devicePubkeys] as string[], chunks,
90
+ ...(v.nextRotation === undefined ? {} : { nextRotation: v.nextRotation as number }) }
91
+ }
92
+
93
+ /** Integrity check before decrypting. Signature and event-ID verification is also required. */
94
+ export function matchesVaultChunk(ref: VaultChunkRef, event: { id: string; pubkey: string; kind: number; content: string }): boolean {
95
+ return event.id === ref.eventId && event.pubkey === ref.author && event.kind === VAULT_EVENT_KIND
96
+ && encoder.encode(event.content).length === ref.contentBytes
97
+ && vaultContentHash(event.content) === ref.contentHash
98
+ }
@@ -0,0 +1,59 @@
1
+ /** Private dataset identities, derived directly from the tree root.
2
+ * Identity ordinals belong in the purpose; the derivation index is rotation.
3
+ * This does not change any existing persona derivation.
4
+ */
5
+ import { derive, fromMnemonic } from 'nsec-tree'
6
+ import type { Identity, TreeRoot } from 'nsec-tree'
7
+
8
+ export type VaultDataset = 'profiles' | 'contacts:owner' | 'contacts:bots' | 'credentials' | 'settings'
9
+ | { dependant: number }
10
+
11
+ const FIXED = new Set(['profiles', 'contacts:owner', 'contacts:bots', 'credentials', 'settings'])
12
+ const MAX_INDEX = 0xffff_ffff
13
+
14
+ function ordinal(value: number): void {
15
+ if (!Number.isInteger(value) || value < 0 || value > MAX_INDEX) {
16
+ throw new Error('Vault ordinal must be an unsigned 32-bit integer')
17
+ }
18
+ }
19
+
20
+ export function vaultPurpose(dataset: VaultDataset): string {
21
+ if (typeof dataset === 'string' && FIXED.has(dataset)) return `signet:vault:${dataset}`
22
+ if (dataset && typeof dataset === 'object' && 'dependant' in dataset) {
23
+ ordinal(dataset.dependant)
24
+ return `signet:vault:contacts:dependant-${dataset.dependant}`
25
+ }
26
+ throw new Error('Unknown vault dataset')
27
+ }
28
+
29
+ /** Strict parser: aliases such as dependant-01 would derive a different key. */
30
+ export function parseVaultPurpose(purpose: string): VaultDataset | null {
31
+ if (typeof purpose !== 'string' || !purpose.startsWith('signet:vault:')) return null
32
+ const suffix = purpose.slice('signet:vault:'.length)
33
+ if (FIXED.has(suffix)) return suffix as VaultDataset
34
+ const match = /^contacts:dependant-(0|[1-9][0-9]*)$/.exec(suffix)
35
+ if (!match) return null
36
+ const dependant = Number(match[1])
37
+ return Number.isInteger(dependant) && dependant <= MAX_INDEX ? { dependant } : null
38
+ }
39
+
40
+ /** Suitable for Heartwood's per-request context; never a registry persona. */
41
+ export function vaultKeyContext(dataset: VaultDataset, rotation = 0): { purpose: string; index: number } {
42
+ ordinal(rotation)
43
+ return { purpose: vaultPurpose(dataset), index: rotation }
44
+ }
45
+
46
+ /** Caller owns and must zeroise the returned child. The supplied root is retained. */
47
+ export function deriveVaultIdentity(root: TreeRoot, dataset: VaultDataset, rotation = 0): Identity {
48
+ const { purpose, index } = vaultKeyContext(dataset, rotation)
49
+ return derive(root, purpose, index)
50
+ }
51
+
52
+ /** Keep root construction and use in one module instance (roots are opaque).
53
+ * Caller must zeroise the returned child; the temporary root is always destroyed.
54
+ */
55
+ export function vaultIdentityFromMnemonic(mnemonic: string, dataset: VaultDataset, rotation = 0): Identity {
56
+ const root = fromMnemonic(mnemonic)
57
+ try { return deriveVaultIdentity(root, dataset, rotation) }
58
+ finally { root.destroy() }
59
+ }