signet-protocol 0.1.0
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/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/anomaly.d.ts +42 -0
- package/dist/anomaly.d.ts.map +1 -0
- package/dist/anomaly.js +209 -0
- package/dist/anomaly.js.map +1 -0
- package/dist/badge.d.ts +56 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/badge.js +171 -0
- package/dist/badge.js.map +1 -0
- package/dist/bonds.d.ts +39 -0
- package/dist/bonds.d.ts.map +1 -0
- package/dist/bonds.js +149 -0
- package/dist/bonds.js.map +1 -0
- package/dist/challenges.d.ts +18 -0
- package/dist/challenges.d.ts.map +1 -0
- package/dist/challenges.js +145 -0
- package/dist/challenges.js.map +1 -0
- package/dist/cold-call.d.ts +74 -0
- package/dist/cold-call.d.ts.map +1 -0
- package/dist/cold-call.js +176 -0
- package/dist/cold-call.js.map +1 -0
- package/dist/compliance.d.ts +82 -0
- package/dist/compliance.d.ts.map +1 -0
- package/dist/compliance.js +478 -0
- package/dist/compliance.js.map +1 -0
- package/dist/connections.d.ts +63 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +170 -0
- package/dist/connections.js.map +1 -0
- package/dist/constants.d.ts +86 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +124 -0
- package/dist/constants.js.map +1 -0
- package/dist/credentials.d.ts +190 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +686 -0
- package/dist/credentials.js.map +1 -0
- package/dist/crypto.d.ts +27 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +75 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +98 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +1118 -0
- package/dist/i18n.js.map +1 -0
- package/dist/identity-bridge.d.ts +52 -0
- package/dist/identity-bridge.d.ts.map +1 -0
- package/dist/identity-bridge.js +228 -0
- package/dist/identity-bridge.js.map +1 -0
- package/dist/identity-tree.d.ts +47 -0
- package/dist/identity-tree.d.ts.map +1 -0
- package/dist/identity-tree.js +69 -0
- package/dist/identity-tree.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/key-derivation.d.ts +43 -0
- package/dist/key-derivation.d.ts.map +1 -0
- package/dist/key-derivation.js +212 -0
- package/dist/key-derivation.js.map +1 -0
- package/dist/lsag.d.ts +23 -0
- package/dist/lsag.d.ts.map +1 -0
- package/dist/lsag.js +35 -0
- package/dist/lsag.js.map +1 -0
- package/dist/merkle.d.ts +19 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +155 -0
- package/dist/merkle.js.map +1 -0
- package/dist/policies.d.ts +22 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +123 -0
- package/dist/policies.js.map +1 -0
- package/dist/range-proof.d.ts +6 -0
- package/dist/range-proof.d.ts.map +1 -0
- package/dist/range-proof.js +45 -0
- package/dist/range-proof.js.map +1 -0
- package/dist/relay.d.ts +106 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +336 -0
- package/dist/relay.js.map +1 -0
- package/dist/ring-signature.d.ts +35 -0
- package/dist/ring-signature.d.ts.map +1 -0
- package/dist/ring-signature.js +56 -0
- package/dist/ring-signature.js.map +1 -0
- package/dist/shamir.d.ts +55 -0
- package/dist/shamir.d.ts.map +1 -0
- package/dist/shamir.js +253 -0
- package/dist/shamir.js.map +1 -0
- package/dist/signet-words.d.ts +42 -0
- package/dist/signet-words.d.ts.map +1 -0
- package/dist/signet-words.js +82 -0
- package/dist/signet-words.js.map +1 -0
- package/dist/store.d.ts +65 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +290 -0
- package/dist/store.js.map +1 -0
- package/dist/trust-score.d.ts +9 -0
- package/dist/trust-score.d.ts.map +1 -0
- package/dist/trust-score.js +186 -0
- package/dist/trust-score.js.map +1 -0
- package/dist/types.d.ts +358 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +21 -0
- package/dist/utils.js.map +1 -0
- package/dist/validation.d.ts +33 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +312 -0
- package/dist/validation.js.map +1 -0
- package/dist/verifiers.d.ts +18 -0
- package/dist/verifiers.d.ts.map +1 -0
- package/dist/verifiers.js +118 -0
- package/dist/verifiers.js.map +1 -0
- package/dist/vouches.d.ts +14 -0
- package/dist/vouches.d.ts.map +1 -0
- package/dist/vouches.js +103 -0
- package/dist/vouches.js.map +1 -0
- package/package.json +76 -0
- package/src/anomaly.ts +307 -0
- package/src/badge.ts +208 -0
- package/src/bonds.ts +203 -0
- package/src/challenges.ts +187 -0
- package/src/cold-call.ts +238 -0
- package/src/compliance.ts +612 -0
- package/src/connections.ts +216 -0
- package/src/constants.ts +146 -0
- package/src/credentials.ts +908 -0
- package/src/crypto.ts +85 -0
- package/src/errors.ts +31 -0
- package/src/i18n.ts +1347 -0
- package/src/identity-bridge.ts +262 -0
- package/src/identity-tree.ts +90 -0
- package/src/index.ts +452 -0
- package/src/lsag.ts +53 -0
- package/src/merkle.ts +176 -0
- package/src/policies.ts +154 -0
- package/src/range-proof.ts +66 -0
- package/src/relay.ts +433 -0
- package/src/ring-signature.ts +76 -0
- package/src/signet-words.ts +122 -0
- package/src/store.ts +336 -0
- package/src/trust-score.ts +208 -0
- package/src/types.ts +482 -0
- package/src/utils.ts +20 -0
- package/src/validation.ts +391 -0
- package/src/verifiers.ts +156 -0
- package/src/vouches.ts +141 -0
package/src/bonds.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Proof-of-Reserve Bond Attestation
|
|
2
|
+
// Non-custodial bond system: verifiers prove Bitcoin address control via BIP-322
|
|
3
|
+
|
|
4
|
+
import { BOND_DOMAIN_SEPARATOR, DEFAULT_BOND_MAX_AGE_SECS, VALID_BOND_ADDRESS_TYPES } from './constants.js';
|
|
5
|
+
import { getTagValue } from './validation.js';
|
|
6
|
+
import { SignetValidationError } from './errors.js';
|
|
7
|
+
import type { BondProof, BondStatus, BondVerificationResult, BIP322Verifier, BitcoinAddressType, NostrEvent } from './types.js';
|
|
8
|
+
|
|
9
|
+
/** Build the canonical BIP-322 message for a bond proof */
|
|
10
|
+
export function buildBondMessage(
|
|
11
|
+
verifierPubkey: string,
|
|
12
|
+
amountSats: number,
|
|
13
|
+
timestamp: number,
|
|
14
|
+
): string {
|
|
15
|
+
return `${BOND_DOMAIN_SEPARATOR}:${verifierPubkey}:${amountSats}:${timestamp}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreateBondProofParams {
|
|
19
|
+
address: string;
|
|
20
|
+
addressType: BitcoinAddressType;
|
|
21
|
+
amountSats: number;
|
|
22
|
+
signature: string;
|
|
23
|
+
verifierPubkey: string;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Create a BondProof object (does not verify the signature) */
|
|
28
|
+
export function createBondProof(params: CreateBondProofParams): BondProof {
|
|
29
|
+
const timestamp = params.timestamp ?? Math.floor(Date.now() / 1000);
|
|
30
|
+
const message = buildBondMessage(params.verifierPubkey, params.amountSats, timestamp);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
address: params.address,
|
|
34
|
+
addressType: params.addressType,
|
|
35
|
+
amountSats: params.amountSats,
|
|
36
|
+
timestamp,
|
|
37
|
+
message,
|
|
38
|
+
signature: params.signature,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface VerifyBondProofOptions {
|
|
43
|
+
/** External BIP-322 verifier. If omitted, signature is not checked (status becomes 'unverified'). */
|
|
44
|
+
verifier?: BIP322Verifier;
|
|
45
|
+
/** Maximum age in seconds (default: DEFAULT_BOND_MAX_AGE_SECS) */
|
|
46
|
+
maxAgeSecs?: number;
|
|
47
|
+
/** Minimum required amount in satoshis */
|
|
48
|
+
requiredSats?: number;
|
|
49
|
+
/** Override current time (unix seconds, for testing) */
|
|
50
|
+
now?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Verify a bond proof structurally and optionally cryptographically */
|
|
54
|
+
export async function verifyBondProof(
|
|
55
|
+
proof: BondProof,
|
|
56
|
+
verifierPubkey: string,
|
|
57
|
+
opts: VerifyBondProofOptions = {},
|
|
58
|
+
): Promise<BondVerificationResult> {
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
61
|
+
const maxAgeSecs = opts.maxAgeSecs ?? DEFAULT_BOND_MAX_AGE_SECS;
|
|
62
|
+
|
|
63
|
+
// Structural validation
|
|
64
|
+
if (!proof.address) {
|
|
65
|
+
errors.push('Bond proof has empty address');
|
|
66
|
+
}
|
|
67
|
+
if (!proof.signature) {
|
|
68
|
+
errors.push('Bond proof has empty signature');
|
|
69
|
+
}
|
|
70
|
+
if (proof.amountSats <= 0) {
|
|
71
|
+
errors.push('Bond amount must be positive');
|
|
72
|
+
}
|
|
73
|
+
if (proof.timestamp > now) {
|
|
74
|
+
errors.push('Bond proof timestamp is in the future');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Verify that the message matches the expected format
|
|
78
|
+
const expectedMessage = buildBondMessage(verifierPubkey, proof.amountSats, proof.timestamp);
|
|
79
|
+
if (proof.message !== expectedMessage) {
|
|
80
|
+
errors.push('Bond proof message does not match expected format for this verifier');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ageSecs = now - proof.timestamp;
|
|
84
|
+
const fresh = ageSecs <= maxAgeSecs;
|
|
85
|
+
|
|
86
|
+
// Check threshold
|
|
87
|
+
const meetsThreshold = opts.requiredSats != null
|
|
88
|
+
? proof.amountSats >= opts.requiredSats
|
|
89
|
+
: null;
|
|
90
|
+
|
|
91
|
+
// Signature verification
|
|
92
|
+
let signatureValid: boolean | null = null;
|
|
93
|
+
|
|
94
|
+
if (opts.verifier && errors.length === 0) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await opts.verifier(proof.address, proof.message, proof.signature);
|
|
97
|
+
signatureValid = result;
|
|
98
|
+
if (!result) {
|
|
99
|
+
errors.push('BIP-322 signature verification failed');
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
errors.push(`BIP-322 verifier threw an error: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
signatureValid = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Determine overall status
|
|
108
|
+
let status: BondStatus;
|
|
109
|
+
if (errors.length > 0) {
|
|
110
|
+
status = 'invalid';
|
|
111
|
+
} else if (signatureValid === null) {
|
|
112
|
+
status = 'unverified';
|
|
113
|
+
} else if (!fresh) {
|
|
114
|
+
status = 'stale';
|
|
115
|
+
} else {
|
|
116
|
+
status = 'valid';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If there are no structural errors but proof is stale, mark as stale (not invalid)
|
|
120
|
+
if (errors.length === 0 && signatureValid !== false && !fresh) {
|
|
121
|
+
status = 'stale';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
status,
|
|
126
|
+
signatureValid,
|
|
127
|
+
fresh,
|
|
128
|
+
ageSecs,
|
|
129
|
+
meetsThreshold,
|
|
130
|
+
errors,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Check whether a bond proof is older than the maximum allowed age */
|
|
135
|
+
export function isBondStale(
|
|
136
|
+
proof: BondProof,
|
|
137
|
+
maxAgeSecs: number = DEFAULT_BOND_MAX_AGE_SECS,
|
|
138
|
+
now?: number,
|
|
139
|
+
): boolean {
|
|
140
|
+
const currentTime = now ?? Math.floor(Date.now() / 1000);
|
|
141
|
+
return currentTime - proof.timestamp > maxAgeSecs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Serialise a BondProof into Nostr event tags (6 tags) */
|
|
145
|
+
export function bondProofToTags(proof: BondProof): string[][] {
|
|
146
|
+
return [
|
|
147
|
+
['bond-address', proof.address],
|
|
148
|
+
['bond-address-type', proof.addressType],
|
|
149
|
+
['bond-amount', String(proof.amountSats)],
|
|
150
|
+
['bond-timestamp', String(proof.timestamp)],
|
|
151
|
+
['bond-message', proof.message],
|
|
152
|
+
['bond-signature', proof.signature],
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Parse a BondProof from Nostr event tags, or return null if no bond tags are present */
|
|
157
|
+
export function parseBondProof(event: NostrEvent | { tags: string[][] }): BondProof | null {
|
|
158
|
+
const address = getTagValue(event as NostrEvent, 'bond-address');
|
|
159
|
+
if (!address) return null;
|
|
160
|
+
|
|
161
|
+
const addressType = getTagValue(event as NostrEvent, 'bond-address-type') as BitcoinAddressType | undefined;
|
|
162
|
+
const amountStr = getTagValue(event as NostrEvent, 'bond-amount');
|
|
163
|
+
const timestampStr = getTagValue(event as NostrEvent, 'bond-timestamp');
|
|
164
|
+
const message = getTagValue(event as NostrEvent, 'bond-message');
|
|
165
|
+
const signature = getTagValue(event as NostrEvent, 'bond-signature');
|
|
166
|
+
|
|
167
|
+
if (!addressType || !amountStr || !timestampStr || !message || !signature) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const amountSats = parseInt(amountStr, 10);
|
|
172
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
173
|
+
|
|
174
|
+
if (isNaN(amountSats) || isNaN(timestamp)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
address,
|
|
180
|
+
addressType,
|
|
181
|
+
amountSats,
|
|
182
|
+
timestamp,
|
|
183
|
+
message,
|
|
184
|
+
signature,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Check whether a bond proof meets a required amount threshold */
|
|
189
|
+
export function checkBondCompliance(
|
|
190
|
+
proof: BondProof | null,
|
|
191
|
+
requiredSats: number,
|
|
192
|
+
): { meets: boolean; reason?: string } {
|
|
193
|
+
if (!proof) {
|
|
194
|
+
return { meets: false, reason: 'No bond proof provided' };
|
|
195
|
+
}
|
|
196
|
+
if (proof.amountSats < requiredSats) {
|
|
197
|
+
return {
|
|
198
|
+
meets: false,
|
|
199
|
+
reason: `Bond amount ${proof.amountSats} sats is below required ${requiredSats} sats`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return { meets: true };
|
|
203
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Verifier Challenge (kind 31000, type: challenge)
|
|
2
|
+
// Verifier Revocation (kind 31000, type: revocation)
|
|
3
|
+
|
|
4
|
+
import { createAttestation } from 'nostr-attestations';
|
|
5
|
+
import { parseAttestation } from 'nostr-attestations';
|
|
6
|
+
import { ATTESTATION_KIND, ATTESTATION_TYPES, DEFAULT_REVOCATION_THRESHOLD, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
|
|
7
|
+
import { signEvent, getPublicKey } from './crypto.js';
|
|
8
|
+
import { getTagValue } from './validation.js';
|
|
9
|
+
import type {
|
|
10
|
+
NostrEvent,
|
|
11
|
+
UnsignedEvent,
|
|
12
|
+
ChallengeParams,
|
|
13
|
+
RevocationParams,
|
|
14
|
+
ParsedChallenge,
|
|
15
|
+
ParsedRevocation,
|
|
16
|
+
ChallengeReason,
|
|
17
|
+
BondAction,
|
|
18
|
+
RevocationScope,
|
|
19
|
+
SignetTier,
|
|
20
|
+
CryptoAlgorithm,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
|
|
23
|
+
// --- Challenge attestation ---
|
|
24
|
+
|
|
25
|
+
/** Build an unsigned challenge event */
|
|
26
|
+
export function buildChallengeEvent(
|
|
27
|
+
reporterPubkey: string,
|
|
28
|
+
params: ChallengeParams
|
|
29
|
+
): UnsignedEvent {
|
|
30
|
+
const template = createAttestation({
|
|
31
|
+
type: ATTESTATION_TYPES.CHALLENGE,
|
|
32
|
+
identifier: params.verifierPubkey,
|
|
33
|
+
subject: params.verifierPubkey,
|
|
34
|
+
occurredAt: params.occurredAt,
|
|
35
|
+
summary: `Challenge: ${params.reason}`,
|
|
36
|
+
content: params.evidence,
|
|
37
|
+
tags: [
|
|
38
|
+
['reason', params.reason],
|
|
39
|
+
['evidence-type', params.evidenceType],
|
|
40
|
+
['reporter-tier', String(params.reporterTier)],
|
|
41
|
+
['algo', DEFAULT_CRYPTO_ALGORITHM],
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...template,
|
|
47
|
+
pubkey: reporterPubkey,
|
|
48
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Create and sign a verifier challenge */
|
|
53
|
+
export async function createChallenge(
|
|
54
|
+
reporterPrivateKey: string,
|
|
55
|
+
params: ChallengeParams
|
|
56
|
+
): Promise<NostrEvent> {
|
|
57
|
+
const pubkey = getPublicKey(reporterPrivateKey);
|
|
58
|
+
const event = buildChallengeEvent(pubkey, params);
|
|
59
|
+
return signEvent(event, reporterPrivateKey);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse a challenge event */
|
|
63
|
+
export function parseChallenge(event: NostrEvent): ParsedChallenge | null {
|
|
64
|
+
const base = parseAttestation(event);
|
|
65
|
+
if (!base) return null;
|
|
66
|
+
if (base.type !== ATTESTATION_TYPES.CHALLENGE) return null;
|
|
67
|
+
|
|
68
|
+
const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
|
|
69
|
+
const verifierPubkey = base.identifier ?? '';
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
verifierPubkey,
|
|
73
|
+
reason: (getTagValue(event, 'reason') || 'other') as ChallengeReason,
|
|
74
|
+
evidenceType: getTagValue(event, 'evidence-type') || '',
|
|
75
|
+
reporterTier: (() => { const t = parseInt(getTagValue(event, 'reporter-tier') || '1', 10); return (t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
|
|
76
|
+
algorithm,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Revocation attestation ---
|
|
81
|
+
|
|
82
|
+
/** Build an unsigned revocation event */
|
|
83
|
+
export function buildRevocationEvent(
|
|
84
|
+
authorityPubkey: string,
|
|
85
|
+
params: RevocationParams
|
|
86
|
+
): UnsignedEvent {
|
|
87
|
+
const template = createAttestation({
|
|
88
|
+
type: ATTESTATION_TYPES.REVOCATION,
|
|
89
|
+
identifier: params.verifierPubkey,
|
|
90
|
+
subject: params.verifierPubkey,
|
|
91
|
+
summary: params.summary,
|
|
92
|
+
content: params.summary,
|
|
93
|
+
tags: [
|
|
94
|
+
['challenge', params.challengeEventId],
|
|
95
|
+
['confirmations', String(params.confirmations)],
|
|
96
|
+
['bond-action', params.bondAction],
|
|
97
|
+
['scope', params.scope],
|
|
98
|
+
['effective', String(params.effectiveAt)],
|
|
99
|
+
['algo', DEFAULT_CRYPTO_ALGORITHM],
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...template,
|
|
105
|
+
pubkey: authorityPubkey,
|
|
106
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Create and sign a verifier revocation */
|
|
111
|
+
export async function createRevocation(
|
|
112
|
+
authorityPrivateKey: string,
|
|
113
|
+
params: RevocationParams
|
|
114
|
+
): Promise<NostrEvent> {
|
|
115
|
+
const pubkey = getPublicKey(authorityPrivateKey);
|
|
116
|
+
const event = buildRevocationEvent(pubkey, params);
|
|
117
|
+
return signEvent(event, authorityPrivateKey);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Parse a revocation event */
|
|
121
|
+
export function parseRevocation(event: NostrEvent): ParsedRevocation | null {
|
|
122
|
+
const base = parseAttestation(event);
|
|
123
|
+
if (!base) return null;
|
|
124
|
+
if (base.type !== ATTESTATION_TYPES.REVOCATION) return null;
|
|
125
|
+
|
|
126
|
+
const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
|
|
127
|
+
const verifierPubkey = base.identifier ?? '';
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
verifierPubkey,
|
|
131
|
+
challengeEventId: getTagValue(event, 'challenge') || '',
|
|
132
|
+
confirmations: (() => { const c = parseInt(getTagValue(event, 'confirmations') || '0', 10); return isNaN(c) || c < 0 ? 0 : c; })(),
|
|
133
|
+
bondAction: (getTagValue(event, 'bond-action') || 'held') as BondAction,
|
|
134
|
+
scope: (getTagValue(event, 'scope') || 'full') as RevocationScope,
|
|
135
|
+
effectiveAt: (() => { const e = parseInt(getTagValue(event, 'effective') || '0', 10); return isNaN(e) ? 0 : e; })(),
|
|
136
|
+
algorithm,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Count Tier 3+ confirmations for a challenge */
|
|
141
|
+
export function countChallengeConfirmations(
|
|
142
|
+
challengeEventId: string,
|
|
143
|
+
confirmationEvents: NostrEvent[],
|
|
144
|
+
credentialEvents: NostrEvent[]
|
|
145
|
+
): number {
|
|
146
|
+
// Build a set of Tier 3+ pubkeys
|
|
147
|
+
const tier3Plus = new Set<string>();
|
|
148
|
+
for (const cred of credentialEvents) {
|
|
149
|
+
if (cred.kind !== ATTESTATION_KIND) continue;
|
|
150
|
+
if (getTagValue(cred, 'type') !== ATTESTATION_TYPES.CREDENTIAL) continue;
|
|
151
|
+
const tier = getTagValue(cred, 'tier');
|
|
152
|
+
const tierNum = tier ? parseInt(tier, 10) : NaN;
|
|
153
|
+
if (!isNaN(tierNum) && tierNum >= 3) {
|
|
154
|
+
const dTag = getTagValue(cred, 'd') || '';
|
|
155
|
+
const pTag = getTagValue(cred, 'p');
|
|
156
|
+
let subject: string;
|
|
157
|
+
if (dTag.startsWith('assertion:') && pTag) {
|
|
158
|
+
subject = pTag;
|
|
159
|
+
} else {
|
|
160
|
+
subject = dTag.startsWith('credential:') ? dTag.slice('credential:'.length) : dTag;
|
|
161
|
+
}
|
|
162
|
+
if (subject) tier3Plus.add(subject);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Count unique Tier 3+ confirmations
|
|
167
|
+
const confirmedBy = new Set<string>();
|
|
168
|
+
for (const conf of confirmationEvents) {
|
|
169
|
+
// Confirmation events reference the challenge
|
|
170
|
+
const refChallenge = getTagValue(conf, 'challenge');
|
|
171
|
+
if (refChallenge !== challengeEventId) continue;
|
|
172
|
+
|
|
173
|
+
if (tier3Plus.has(conf.pubkey) && !confirmedBy.has(conf.pubkey)) {
|
|
174
|
+
confirmedBy.add(conf.pubkey);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return confirmedBy.size;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Check if a challenge has reached the revocation threshold */
|
|
182
|
+
export function hasReachedRevocationThreshold(
|
|
183
|
+
confirmations: number,
|
|
184
|
+
threshold: number = DEFAULT_REVOCATION_THRESHOLD
|
|
185
|
+
): boolean {
|
|
186
|
+
return confirmations >= threshold;
|
|
187
|
+
}
|
package/src/cold-call.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Cold-Call Verification
|
|
2
|
+
// Allows customers to verify unknown institutional callers via:
|
|
3
|
+
// .well-known/signet.json — institution publishes its pubkeys
|
|
4
|
+
// Ephemeral ECDH — customer generates a one-time keypair
|
|
5
|
+
// Spoken-token words — both sides derive the same words independently
|
|
6
|
+
|
|
7
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
8
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
9
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
10
|
+
import { SignetValidationError } from './errors.js';
|
|
11
|
+
import type { InstitutionKeys } from './types.js';
|
|
12
|
+
import {
|
|
13
|
+
COLD_CALL_CONTEXT,
|
|
14
|
+
COLD_CALL_EPOCH_SECONDS,
|
|
15
|
+
NATO_ALPHABET,
|
|
16
|
+
WELL_KNOWN_MAX_PUBKEYS,
|
|
17
|
+
WELL_KNOWN_MAX_SIZE,
|
|
18
|
+
WELL_KNOWN_PATH,
|
|
19
|
+
} from './constants.js';
|
|
20
|
+
import { deriveWords } from './signet-words.js';
|
|
21
|
+
|
|
22
|
+
// ── .well-known/signet.json fetching ─────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch and validate an institution's verification keys from `.well-known/signet.json`.
|
|
26
|
+
*
|
|
27
|
+
* Always uses HTTPS. Enforces a 10 KB size limit, version 1, at most 20 pubkeys,
|
|
28
|
+
* and validates that each pubkey is a 64-char lowercase hex string.
|
|
29
|
+
*
|
|
30
|
+
* @param domain - The institution's domain (e.g. `'acmelegal.com'`). Do NOT include scheme.
|
|
31
|
+
* @returns Validated InstitutionKeys object.
|
|
32
|
+
* @throws {SignetValidationError} If the response is invalid, too large, or fails validation.
|
|
33
|
+
*/
|
|
34
|
+
export async function fetchInstitutionKeys(domain: string): Promise<InstitutionKeys> {
|
|
35
|
+
const url = `https://${domain}${WELL_KNOWN_PATH}`;
|
|
36
|
+
|
|
37
|
+
// Fetch with timeout (throws on network error — caller decides how to handle)
|
|
38
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new SignetValidationError(`.well-known/signet.json fetch failed: HTTP ${response.status}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Early-exit on Content-Length if available (avoids reading large bodies into memory)
|
|
44
|
+
const contentLength = response.headers.get('content-length');
|
|
45
|
+
if (contentLength && parseInt(contentLength, 10) > WELL_KNOWN_MAX_SIZE) {
|
|
46
|
+
throw new SignetValidationError(`.well-known/signet.json exceeds ${WELL_KNOWN_MAX_SIZE} bytes`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
|
|
51
|
+
// Enforce size limit (Content-Length may be absent for chunked responses)
|
|
52
|
+
if (text.length > WELL_KNOWN_MAX_SIZE) {
|
|
53
|
+
throw new SignetValidationError(`.well-known/signet.json exceeds ${WELL_KNOWN_MAX_SIZE} bytes`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Parse with runtime type guard
|
|
57
|
+
let data: unknown;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new SignetValidationError('.well-known/signet.json is not valid JSON');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof data !== 'object' || data === null) {
|
|
65
|
+
throw new SignetValidationError('.well-known/signet.json must be a JSON object');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const obj = data as Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
if (obj['version'] !== 1) {
|
|
71
|
+
throw new SignetValidationError(`Unsupported signet.json version: ${obj['version']}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!obj['name'] || typeof obj['name'] !== 'string') {
|
|
75
|
+
throw new SignetValidationError('Missing or invalid name field');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Array.isArray(obj['pubkeys']) || (obj['pubkeys'] as unknown[]).length === 0) {
|
|
79
|
+
throw new SignetValidationError('Missing or empty pubkeys array');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const pubkeys = obj['pubkeys'] as unknown[];
|
|
83
|
+
|
|
84
|
+
if (pubkeys.length > WELL_KNOWN_MAX_PUBKEYS) {
|
|
85
|
+
throw new SignetValidationError(`Too many pubkeys (max ${WELL_KNOWN_MAX_PUBKEYS})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const pk of pubkeys) {
|
|
89
|
+
if (typeof pk !== 'object' || pk === null) {
|
|
90
|
+
throw new SignetValidationError('Each pubkey entry must be an object');
|
|
91
|
+
}
|
|
92
|
+
const entry = pk as Record<string, unknown>;
|
|
93
|
+
if (typeof entry['pubkey'] !== 'string' || !/^[0-9a-f]{64}$/i.test(entry['pubkey'])) {
|
|
94
|
+
throw new SignetValidationError(`Invalid pubkey format: ${entry['id'] ?? '(unknown)'}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return data as InstitutionKeys;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Session code generation ───────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate a human-readable session code from an ephemeral pubkey.
|
|
105
|
+
*
|
|
106
|
+
* Format: `NATOWORD-NNNN` (e.g. `BRAVO-7742`).
|
|
107
|
+
* The code is derived deterministically from the SHA-256 of the pubkey bytes.
|
|
108
|
+
*
|
|
109
|
+
* @param ephemeralPubkey - 64-char hex x-only secp256k1 public key.
|
|
110
|
+
* @returns Session code string.
|
|
111
|
+
* @throws {SignetValidationError} If the pubkey is not 64 hex characters.
|
|
112
|
+
*/
|
|
113
|
+
export function generateSessionCode(ephemeralPubkey: string): string {
|
|
114
|
+
if (!/^[0-9a-f]{64}$/i.test(ephemeralPubkey)) {
|
|
115
|
+
throw new SignetValidationError('Invalid ephemeral pubkey format — must be 64-char hex');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hash = sha256(hexToBytes(ephemeralPubkey));
|
|
119
|
+
const natoIndex = hash[0] % NATO_ALPHABET.length;
|
|
120
|
+
// Use 5 bytes to derive a 0–9999 digit (avoids bias vs single byte % 10000)
|
|
121
|
+
const raw = ((hash[1] << 24) | (hash[2] << 16) | (hash[3] << 8) | hash[4]) >>> 0;
|
|
122
|
+
const digits = raw % 10000;
|
|
123
|
+
return `${NATO_ALPHABET[natoIndex]}-${digits.toString().padStart(4, '0')}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Word derivation ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Derive spoken words from a cold-call ECDH shared secret.
|
|
130
|
+
*
|
|
131
|
+
* Uses context `"signet:cold-call"` (domain-separated from the "Signet me"
|
|
132
|
+
* context `"signet:verify"`), so cold-call and peer-verification words never clash.
|
|
133
|
+
*
|
|
134
|
+
* @param ecdhSecret - 32-byte ECDH shared secret (Uint8Array or 64-char hex).
|
|
135
|
+
* @param counter - Epoch counter (default: current 30-second epoch).
|
|
136
|
+
* @param wordCount - Number of words to derive (1-16, default 3).
|
|
137
|
+
* @returns Array of spoken-clarity words.
|
|
138
|
+
*/
|
|
139
|
+
export function deriveColdCallWords(
|
|
140
|
+
ecdhSecret: Uint8Array | string,
|
|
141
|
+
counter?: number,
|
|
142
|
+
wordCount: number = 3,
|
|
143
|
+
): string[] {
|
|
144
|
+
const currentCounter = counter ?? Math.floor(Date.now() / 1000 / COLD_CALL_EPOCH_SECONDS);
|
|
145
|
+
return deriveWords(ecdhSecret, currentCounter, wordCount, COLD_CALL_CONTEXT);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── ECDH cold-call flow ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** The result of initiating a cold-call verification session. */
|
|
151
|
+
export interface ColdCallSession {
|
|
152
|
+
/** 64-char hex x-only ephemeral public key — share this with the institution. */
|
|
153
|
+
ephemeralPubkey: string;
|
|
154
|
+
/** Human-readable session code derived from the ephemeral pubkey (e.g. `"BRAVO-7742"`). */
|
|
155
|
+
sessionCode: string;
|
|
156
|
+
/** Spoken words the customer expects to hear from the institution. */
|
|
157
|
+
words: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Customer side: generate a fresh ephemeral keypair, perform ECDH with the
|
|
162
|
+
* institution's pubkey, and derive the expected spoken words.
|
|
163
|
+
*
|
|
164
|
+
* The customer MUST share `ephemeralPubkey` (or `sessionCode`) with the institution
|
|
165
|
+
* so the institution can perform the matching ECDH.
|
|
166
|
+
*
|
|
167
|
+
* The shared secret is SHA-256 of the x-coordinate of the ECDH point.
|
|
168
|
+
*
|
|
169
|
+
* @param institutionPubkey - 64-char hex x-only secp256k1 pubkey from `.well-known/signet.json`.
|
|
170
|
+
* @param wordCount - Number of words to derive (default 3).
|
|
171
|
+
* @returns ColdCallSession with ephemeral pubkey, session code, and expected words.
|
|
172
|
+
* @throws {SignetCryptoError} If ECDH produces an invalid point.
|
|
173
|
+
*/
|
|
174
|
+
export function initiateColdCallVerification(
|
|
175
|
+
institutionPubkey: string,
|
|
176
|
+
wordCount: number = 3,
|
|
177
|
+
): ColdCallSession {
|
|
178
|
+
// Generate ephemeral keypair
|
|
179
|
+
const ephPriv = secp256k1.utils.randomSecretKey();
|
|
180
|
+
const ephPubCompressed = secp256k1.getPublicKey(ephPriv, true); // 33 bytes, 02-prefix
|
|
181
|
+
|
|
182
|
+
// ECDH: customer ephemeral private × institution public
|
|
183
|
+
// getSharedSecret expects a compressed pubkey with 02/03 prefix
|
|
184
|
+
const sharedPoint = secp256k1.getSharedSecret(ephPriv, hexToBytes('02' + institutionPubkey));
|
|
185
|
+
|
|
186
|
+
// Shared secret = SHA-256(x-coordinate of the ECDH point)
|
|
187
|
+
// sharedPoint is a 65-byte uncompressed point or 33-byte compressed; take bytes [1..33]
|
|
188
|
+
const xBytes = sharedPoint.slice(1, 33);
|
|
189
|
+
const sharedSecret = sha256(xBytes);
|
|
190
|
+
|
|
191
|
+
// Derive words at the current epoch
|
|
192
|
+
const words = deriveColdCallWords(sharedSecret, undefined, wordCount);
|
|
193
|
+
|
|
194
|
+
// Session code from the ephemeral pubkey (x-only, strip 02 prefix)
|
|
195
|
+
const ephPubHex = bytesToHex(ephPubCompressed).slice(2);
|
|
196
|
+
const sessionCode = generateSessionCode(ephPubHex);
|
|
197
|
+
|
|
198
|
+
// Zero ephemeral private key bytes (defence-in-depth; GC still owns the buffer)
|
|
199
|
+
ephPriv.fill(0);
|
|
200
|
+
|
|
201
|
+
return { ephemeralPubkey: ephPubHex, sessionCode, words };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Institution side: receive the customer's ephemeral pubkey, perform ECDH with
|
|
206
|
+
* the institution's own private key, and derive the same spoken words.
|
|
207
|
+
*
|
|
208
|
+
* If the institution reads out the returned words and they match what the customer
|
|
209
|
+
* sees, the caller is verified as genuine.
|
|
210
|
+
*
|
|
211
|
+
* @param institutionPrivkey - 64-char hex secp256k1 private key.
|
|
212
|
+
* @param ephemeralPubkey - 64-char hex x-only ephemeral pubkey from the customer.
|
|
213
|
+
* @param wordCount - Number of words to derive (must match customer's wordCount, default 3).
|
|
214
|
+
* @returns Array of spoken words — should match the customer's displayed words.
|
|
215
|
+
* @throws {SignetCryptoError} If ECDH produces an invalid point.
|
|
216
|
+
*/
|
|
217
|
+
export function completeColdCallVerification(
|
|
218
|
+
institutionPrivkey: string,
|
|
219
|
+
ephemeralPubkey: string,
|
|
220
|
+
wordCount: number = 3,
|
|
221
|
+
): string[] {
|
|
222
|
+
if (!/^[0-9a-f]{64}$/i.test(institutionPrivkey)) {
|
|
223
|
+
throw new SignetValidationError('Invalid institution private key format — must be 64-char hex');
|
|
224
|
+
}
|
|
225
|
+
if (!/^[0-9a-f]{64}$/i.test(ephemeralPubkey)) {
|
|
226
|
+
throw new SignetValidationError('Invalid ephemeral pubkey format — must be 64-char hex');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ECDH: institution private × customer ephemeral public
|
|
230
|
+
const privBytes = hexToBytes(institutionPrivkey);
|
|
231
|
+
const sharedPoint = secp256k1.getSharedSecret(privBytes, hexToBytes('02' + ephemeralPubkey));
|
|
232
|
+
privBytes.fill(0);
|
|
233
|
+
|
|
234
|
+
const xBytes = sharedPoint.slice(1, 33);
|
|
235
|
+
const sharedSecret = sha256(xBytes);
|
|
236
|
+
|
|
237
|
+
return deriveColdCallWords(sharedSecret, undefined, wordCount);
|
|
238
|
+
}
|