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.
- package/README.md +15 -10
- package/dist/bot-ownership.d.ts +61 -0
- package/dist/bot-ownership.d.ts.map +1 -0
- package/dist/bot-ownership.js +88 -0
- package/dist/bot-ownership.js.map +1 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +4 -0
- package/dist/crypto.js.map +1 -1
- package/dist/experimental.d.ts +19 -0
- package/dist/experimental.d.ts.map +1 -0
- package/dist/experimental.js +16 -0
- package/dist/experimental.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/url-auth.d.ts.map +1 -1
- package/dist/url-auth.js +2 -1
- package/dist/url-auth.js.map +1 -1
- package/dist/utils.d.ts +46 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +127 -0
- package/dist/utils.js.map +1 -1
- package/dist/vault-checkpoint.d.ts +50 -0
- package/dist/vault-checkpoint.d.ts.map +1 -0
- package/dist/vault-checkpoint.js +84 -0
- package/dist/vault-checkpoint.js.map +1 -0
- package/dist/vault-keys.d.ts +19 -0
- package/dist/vault-keys.d.ts.map +1 -0
- package/dist/vault-keys.js +57 -0
- package/dist/vault-keys.js.map +1 -0
- package/dist/vault-recovery.d.ts +99 -0
- package/dist/vault-recovery.d.ts.map +1 -0
- package/dist/vault-recovery.js +305 -0
- package/dist/vault-recovery.js.map +1 -0
- package/dist/vault-relay.d.ts +29 -0
- package/dist/vault-relay.d.ts.map +1 -0
- package/dist/vault-relay.js +131 -0
- package/dist/vault-relay.js.map +1 -0
- package/llms-full.txt +3744 -0
- package/llms.txt +134 -0
- package/package.json +15 -6
- package/src/bot-ownership.ts +77 -0
- package/src/crypto.ts +5 -0
- package/src/experimental.ts +25 -0
- package/src/index.ts +3 -0
- package/src/url-auth.ts +2 -1
- package/src/utils.ts +122 -0
- package/src/vault-checkpoint.ts +98 -0
- package/src/vault-keys.ts +59 -0
- package/src/vault-recovery.ts +284 -0
- 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.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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": "^
|
|
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
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(
|
|
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
|
+
}
|