@shroud-fi/core 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shroud-fi/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "secp256k1 stealth-address identity layer for the ShroudFi privacy SDK. EIP-5564/6538 primitives every other package builds on.",
5
5
  "keywords": [
6
6
  "shroudfi",
@@ -28,7 +28,10 @@
28
28
  }
29
29
  },
30
30
  "files": [
31
- "dist"
31
+ "dist",
32
+ "src",
33
+ "tsconfig.json",
34
+ "README.md"
32
35
  ],
33
36
  "dependencies": {
34
37
  "@noble/curves": "^1.6.0",
@@ -41,7 +44,7 @@
41
44
  "viem": "^2.21.0",
42
45
  "vitest": "^2.0.0",
43
46
  "typescript": "^5.6.0",
44
- "@shroud-fi/transport": "0.1.3"
47
+ "@shroud-fi/transport": "0.1.4"
45
48
  },
46
49
  "publishConfig": {
47
50
  "access": "public"
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Stealth Key Computation (Recipient Side)
3
+ *
4
+ * Given the scanning private key, spending private key, and the
5
+ * ephemeral public key from an announcement, derive the stealth
6
+ * private key that controls the stealth address.
7
+ *
8
+ * 1. ECDH: sharedSecret = scanningPrivKey * ephemeralPubKey
9
+ * 2. Hash: hashedSecret = keccak256(compress(sharedSecret))
10
+ * 3. Stealth private key: p_stealth = (spendingPrivKey + hashedSecret) mod n
11
+ */
12
+
13
+ import { secp256k1 } from '@noble/curves/secp256k1';
14
+ import { keccak_256 } from '@noble/hashes/sha3';
15
+ import type { ScanningKey, SpendingKey } from './types.js';
16
+ import { InvalidKeyError, StealthAddressError } from './errors.js';
17
+
18
+ /**
19
+ * Compute the stealth private key for a detected payment.
20
+ *
21
+ * @param scanningKey - Recipient's scanning private key
22
+ * @param spendingKey - Recipient's spending private key
23
+ * @param ephemeralPubKey - Ephemeral public key from the announcement (33 bytes compressed)
24
+ * @returns 32-byte stealth private key
25
+ * @throws InvalidKeyError if ephemeral public key is invalid
26
+ * @throws StealthAddressError if the derived stealth key is invalid
27
+ */
28
+ export function computeStealthKey(
29
+ scanningKey: ScanningKey,
30
+ spendingKey: SpendingKey,
31
+ ephemeralPubKey: Uint8Array,
32
+ ): Uint8Array {
33
+ // Validate ephemeral public key
34
+ let ephemeralPoint: InstanceType<typeof secp256k1.ProjectivePoint>;
35
+ try {
36
+ ephemeralPoint = secp256k1.ProjectivePoint.fromHex(ephemeralPubKey);
37
+ } catch {
38
+ throw new InvalidKeyError('Invalid ephemeral public key');
39
+ }
40
+
41
+ // Step 1: ECDH shared secret S = scanningPriv * P_ephemeral
42
+ const scanScalar = bytesToBigInt(scanningKey.bytes);
43
+ const sharedSecretPoint = ephemeralPoint.multiply(scanScalar);
44
+
45
+ // Step 2: Hash the compressed shared secret
46
+ const sharedSecretCompressed = sharedSecretPoint.toRawBytes(true); // 33 bytes
47
+ const hashedSecret = keccak_256(sharedSecretCompressed); // 32 bytes
48
+
49
+ // Step 3: Stealth private key = (spendingPriv + hashedSecret) mod n
50
+ const n = secp256k1.CURVE.n;
51
+ const spendScalar = bytesToBigInt(spendingKey.bytes);
52
+ const hashedScalar = bytesToBigInt(hashedSecret) % n;
53
+ const stealthScalar = (spendScalar + hashedScalar) % n;
54
+
55
+ if (stealthScalar === 0n) {
56
+ throw new StealthAddressError('Derived stealth key is zero');
57
+ }
58
+
59
+ // Convert BigInt back to 32-byte Uint8Array (big-endian)
60
+ return bigIntToBytes(stealthScalar, 32);
61
+ }
62
+
63
+ /** Convert a Uint8Array to a BigInt (big-endian). */
64
+ function bytesToBigInt(bytes: Uint8Array): bigint {
65
+ let result = 0n;
66
+ for (const byte of bytes) {
67
+ result = (result << 8n) | BigInt(byte);
68
+ }
69
+ return result;
70
+ }
71
+
72
+ /** Convert a BigInt to a fixed-length Uint8Array (big-endian). */
73
+ function bigIntToBytes(value: bigint, length: number): Uint8Array {
74
+ const result = new Uint8Array(length);
75
+ let remaining = value;
76
+ for (let i = length - 1; i >= 0; i--) {
77
+ result[i] = Number(remaining & 0xffn);
78
+ remaining >>= 8n;
79
+ }
80
+ return result;
81
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ShroudFi Core Constants
3
+ *
4
+ * EIP-5564 / ERC-6538 scheme configuration for Base (EVM).
5
+ */
6
+
7
+ /** EIP-5564 scheme ID for secp256k1 + view tags. */
8
+ export const SCHEME_ID = 1;
9
+
10
+ /** HKDF salt used for all key derivation from master seed. */
11
+ export const HKDF_SALT = 'ShroudFi-EVM-v1';
12
+
13
+ /**
14
+ * ERC-5564 Announcer singleton address.
15
+ * Deterministic CREATE2 deployment -- same address on every EVM chain including Base.
16
+ */
17
+ export const ERC5564_ANNOUNCER_ADDRESS =
18
+ '0x55649E01B5Df198D18D95b5cc5051630cfD45564' as const;
19
+
20
+ /**
21
+ * ERC-6538 Stealth Meta-Address Registry singleton address.
22
+ * Deterministic CREATE2 deployment -- same address on every EVM chain including Base.
23
+ */
24
+ export const ERC6538_REGISTRY_ADDRESS =
25
+ '0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538' as const;
package/src/errors.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ShroudFi Privacy-Safe Error Hierarchy
3
+ *
4
+ * Rules:
5
+ * - NEVER include private keys, seeds, or scalars in error messages.
6
+ * - NEVER include amounts or balances.
7
+ * - NEVER include stealth addresses or ephemeral keys.
8
+ * - Safe to include: error codes, generic descriptions.
9
+ */
10
+
11
+ /** Base error for all ShroudFi operations. */
12
+ export class ShroudFiError extends Error {
13
+ readonly code: string;
14
+
15
+ constructor(code: string, message: string) {
16
+ super(message);
17
+ this.code = code;
18
+ this.name = 'ShroudFiError';
19
+ Object.setPrototypeOf(this, new.target.prototype);
20
+ }
21
+ }
22
+
23
+ /** Thrown when a private or public key fails validation. */
24
+ export class InvalidKeyError extends ShroudFiError {
25
+ constructor(message: string = 'Invalid key material') {
26
+ super('INVALID_KEY', message);
27
+ this.name = 'InvalidKeyError';
28
+ }
29
+ }
30
+
31
+ /** Thrown when a stealth meta-address cannot be parsed or is malformed. */
32
+ export class InvalidMetaAddressError extends ShroudFiError {
33
+ constructor(message: string = 'Invalid stealth meta-address') {
34
+ super('INVALID_META_ADDRESS', message);
35
+ this.name = 'InvalidMetaAddressError';
36
+ }
37
+ }
38
+
39
+ /** Thrown when stealth address generation or verification fails. */
40
+ export class StealthAddressError extends ShroudFiError {
41
+ constructor(message: string = 'Stealth address operation failed') {
42
+ super('STEALTH_ADDRESS_ERROR', message);
43
+ this.name = 'StealthAddressError';
44
+ }
45
+ }
46
+
47
+ /** Discriminated union result type -- avoids throwing for sensitive operations. */
48
+ export type Result<T> =
49
+ | { readonly ok: true; readonly value: T }
50
+ | { readonly ok: false; readonly error: ShroudFiError };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Agent Identity Factory
3
+ *
4
+ * Creates a complete ShroudFi agent identity (key hierarchy + meta-address)
5
+ * from an optional master seed. If no seed is provided, generates 32
6
+ * cryptographically random bytes via crypto.getRandomValues.
7
+ */
8
+
9
+ import { secp256k1 } from '@noble/curves/secp256k1';
10
+ import { randomBytes } from '@noble/hashes/utils';
11
+ import type { KeyHierarchy, StealthMetaAddress } from './types.js';
12
+ import { deriveKeys } from './keys.js';
13
+ import { SCHEME_ID } from './constants.js';
14
+
15
+ export interface AgentIdentity {
16
+ readonly keys: KeyHierarchy;
17
+ readonly metaAddress: StealthMetaAddress;
18
+ }
19
+
20
+ /**
21
+ * Create a full agent identity with key hierarchy and meta-address.
22
+ *
23
+ * @param masterSeed - Optional 32-byte seed. If omitted, generates random bytes.
24
+ * @returns AgentIdentity with derived keys and meta-address
25
+ */
26
+ export function createAgentIdentity(masterSeed?: Uint8Array): AgentIdentity {
27
+ const seed = masterSeed ?? randomBytes(32);
28
+ const keys = deriveKeys(seed);
29
+
30
+ // Derive compressed public keys from the private keys.
31
+ // EIP-5564 scheme 1 has only spending + viewing keys, where "viewing" is the
32
+ // key used for ECDH scanning. In the @shroud-fi/core hierarchy we name that
33
+ // ECDH-scan key `scanningKey` (its semantic role). The separate `viewingKey`
34
+ // is reserved for future compliance/delegated-disclosure flows.
35
+ const spendingPubKey = secp256k1.getPublicKey(keys.spendingKey.bytes, true);
36
+ const viewingPubKey = secp256k1.getPublicKey(keys.scanningKey.bytes, true);
37
+
38
+ const metaAddress: StealthMetaAddress = {
39
+ spendingPubKey,
40
+ viewingPubKey,
41
+ schemeId: SCHEME_ID,
42
+ };
43
+
44
+ return { keys, metaAddress };
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Types
2
+ export type {
3
+ SpendingKey,
4
+ ScanningKey,
5
+ ViewingKey,
6
+ StealthMetaAddress,
7
+ StealthAddressResult,
8
+ KeyHierarchy,
9
+ } from './types.js';
10
+
11
+ // Errors
12
+ export {
13
+ ShroudFiError,
14
+ InvalidKeyError,
15
+ InvalidMetaAddressError,
16
+ StealthAddressError,
17
+ } from './errors.js';
18
+ export type { Result } from './errors.js';
19
+
20
+ // Constants
21
+ export {
22
+ SCHEME_ID,
23
+ HKDF_SALT,
24
+ ERC5564_ANNOUNCER_ADDRESS,
25
+ ERC6538_REGISTRY_ADDRESS,
26
+ } from './constants.js';
27
+
28
+ // Key derivation
29
+ export { deriveKeys } from './keys.js';
30
+
31
+ // Stealth address generation (sender side)
32
+ export { generateStealthAddress } from './stealth.js';
33
+
34
+ // Stealth key computation (recipient side)
35
+ export { computeStealthKey } from './compute-key.js';
36
+
37
+ // View tag utilities
38
+ export { extractViewTag, checkViewTag } from './view-tag.js';
39
+
40
+ // Meta-address encode/decode
41
+ export { encodeMetaAddress, decodeMetaAddress } from './meta-address.js';
42
+
43
+ // Agent identity factory
44
+ export { createAgentIdentity } from './identity.js';
45
+ export type { AgentIdentity } from './identity.js';
package/src/keys.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Key Derivation -- HKDF-SHA256
3
+ *
4
+ * Derives a full KeyHierarchy from a 32-byte master seed using
5
+ * HKDF with domain-separated info labels. Each derived key is
6
+ * validated as a valid secp256k1 scalar (1 < k < n).
7
+ */
8
+
9
+ import { hkdf } from '@noble/hashes/hkdf';
10
+ import { sha256 } from '@noble/hashes/sha256';
11
+ import { secp256k1 } from '@noble/curves/secp256k1';
12
+ import type { KeyHierarchy, SpendingKey, ScanningKey, ViewingKey } from './types.js';
13
+ import { InvalidKeyError } from './errors.js';
14
+ import { HKDF_SALT } from './constants.js';
15
+
16
+ /**
17
+ * Validate that a 32-byte array is a valid secp256k1 private key scalar.
18
+ * Must satisfy: 1 <= k < n (curve order).
19
+ */
20
+ function assertValidScalar(bytes: Uint8Array, label: string): void {
21
+ if (bytes.length !== 32) {
22
+ throw new InvalidKeyError(`Derived ${label} has invalid length`);
23
+ }
24
+ if (!secp256k1.utils.isValidPrivateKey(bytes)) {
25
+ throw new InvalidKeyError(`Derived ${label} is not a valid secp256k1 scalar`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Derive the full ShroudFi key hierarchy from a master seed.
31
+ *
32
+ * @param masterSeed - 32-byte cryptographically random seed
33
+ * @returns KeyHierarchy with spending, scanning, and viewing keys
34
+ * @throws InvalidKeyError if seed length is wrong or a derived key is invalid
35
+ */
36
+ export function deriveKeys(masterSeed: Uint8Array): KeyHierarchy {
37
+ if (masterSeed.length !== 32) {
38
+ throw new InvalidKeyError('Master seed must be exactly 32 bytes');
39
+ }
40
+
41
+ const spendingBytes = hkdf(sha256, masterSeed, HKDF_SALT, 'spending-key', 32);
42
+ assertValidScalar(spendingBytes, 'spending key');
43
+
44
+ const scanningBytes = hkdf(sha256, masterSeed, HKDF_SALT, 'scanning-key', 32);
45
+ assertValidScalar(scanningBytes, 'scanning key');
46
+
47
+ const viewingBytes = hkdf(sha256, masterSeed, HKDF_SALT, 'viewing-key', 32);
48
+ assertValidScalar(viewingBytes, 'viewing key');
49
+
50
+ return {
51
+ spendingKey: { __brand: 'SpendingKey' as const, bytes: spendingBytes } as SpendingKey,
52
+ scanningKey: { __brand: 'ScanningKey' as const, bytes: scanningBytes } as ScanningKey,
53
+ viewingKey: { __brand: 'ViewingKey' as const, bytes: viewingBytes } as ViewingKey,
54
+ };
55
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ERC-6538 Stealth Meta-Address Encode/Decode
3
+ *
4
+ * Format: st:base:0x<spendingPubKey 33 bytes hex><viewingPubKey 33 bytes hex>
5
+ *
6
+ * Both keys are SEC1 compressed secp256k1 public keys (prefix 0x02 or 0x03).
7
+ * Total raw payload: 66 bytes = 132 hex characters.
8
+ */
9
+
10
+ import { secp256k1 } from '@noble/curves/secp256k1';
11
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
12
+ import type { StealthMetaAddress } from './types.js';
13
+ import { InvalidMetaAddressError } from './errors.js';
14
+ import { SCHEME_ID } from './constants.js';
15
+
16
+ const META_ADDRESS_PREFIX = 'st:base:0x';
17
+ const COMPRESSED_KEY_LENGTH = 33; // bytes
18
+ const TOTAL_PAYLOAD_LENGTH = COMPRESSED_KEY_LENGTH * 2; // 66 bytes
19
+
20
+ /**
21
+ * Validate that a byte array is a valid compressed secp256k1 public key.
22
+ * Must be 33 bytes with 0x02 or 0x03 prefix.
23
+ */
24
+ function assertValidCompressedKey(key: Uint8Array, label: string): void {
25
+ if (key.length !== COMPRESSED_KEY_LENGTH) {
26
+ throw new InvalidMetaAddressError(
27
+ `${label} must be ${COMPRESSED_KEY_LENGTH} bytes, got ${key.length}`,
28
+ );
29
+ }
30
+ const prefix = key[0];
31
+ if (prefix !== 0x02 && prefix !== 0x03) {
32
+ throw new InvalidMetaAddressError(
33
+ `${label} must have 0x02 or 0x03 prefix`,
34
+ );
35
+ }
36
+ // Validate it's actually on the curve
37
+ try {
38
+ secp256k1.ProjectivePoint.fromHex(key);
39
+ } catch {
40
+ throw new InvalidMetaAddressError(`${label} is not a valid secp256k1 point`);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Encode a StealthMetaAddress into the ERC-6538 URI format.
46
+ *
47
+ * @param meta - Stealth meta-address containing spending and viewing public keys
48
+ * @returns Encoded string: "st:base:0x<spendHex><viewHex>"
49
+ * @throws InvalidMetaAddressError if keys are invalid
50
+ */
51
+ export function encodeMetaAddress(meta: StealthMetaAddress): string {
52
+ assertValidCompressedKey(meta.spendingPubKey, 'Spending public key');
53
+ assertValidCompressedKey(meta.viewingPubKey, 'Viewing public key');
54
+
55
+ const spendHex = bytesToHex(meta.spendingPubKey);
56
+ const viewHex = bytesToHex(meta.viewingPubKey);
57
+
58
+ return `${META_ADDRESS_PREFIX}${spendHex}${viewHex}`;
59
+ }
60
+
61
+ /**
62
+ * Decode an ERC-6538 URI string into a StealthMetaAddress.
63
+ *
64
+ * @param encoded - String in format "st:base:0x<66 bytes hex>"
65
+ * @returns Parsed StealthMetaAddress
66
+ * @throws InvalidMetaAddressError if format is invalid
67
+ */
68
+ export function decodeMetaAddress(encoded: string): StealthMetaAddress {
69
+ if (!encoded.startsWith(META_ADDRESS_PREFIX)) {
70
+ throw new InvalidMetaAddressError(
71
+ `Meta-address must start with "${META_ADDRESS_PREFIX}"`,
72
+ );
73
+ }
74
+
75
+ const hexPayload = encoded.slice(META_ADDRESS_PREFIX.length);
76
+
77
+ if (hexPayload.length !== TOTAL_PAYLOAD_LENGTH * 2) {
78
+ throw new InvalidMetaAddressError(
79
+ `Meta-address hex payload must be ${TOTAL_PAYLOAD_LENGTH * 2} characters, got ${hexPayload.length}`,
80
+ );
81
+ }
82
+
83
+ let payloadBytes: Uint8Array;
84
+ try {
85
+ payloadBytes = hexToBytes(hexPayload);
86
+ } catch {
87
+ throw new InvalidMetaAddressError('Meta-address contains invalid hex characters');
88
+ }
89
+
90
+ const spendingPubKey = payloadBytes.slice(0, COMPRESSED_KEY_LENGTH);
91
+ const viewingPubKey = payloadBytes.slice(COMPRESSED_KEY_LENGTH);
92
+
93
+ assertValidCompressedKey(spendingPubKey, 'Spending public key');
94
+ assertValidCompressedKey(viewingPubKey, 'Viewing public key');
95
+
96
+ return {
97
+ spendingPubKey,
98
+ viewingPubKey,
99
+ schemeId: SCHEME_ID,
100
+ };
101
+ }
package/src/stealth.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Stealth Address Generation (Sender Side)
3
+ *
4
+ * Implements EIP-5564 scheme 1 (secp256k1):
5
+ * 1. Generate random ephemeral key r, compute R = r*G
6
+ * 2. ECDH: sharedSecret = r * viewingPubKey
7
+ * 3. Hash: hashedSecret = keccak256(compress(sharedSecret))
8
+ * 4. View tag: viewTag = hashedSecret[0]
9
+ * 5. Stealth pubkey: P_stealth = spendingPubKey + hashedSecret * G
10
+ * 6. Stealth address: keccak256(uncompressed64(P_stealth))[12:] -> 20-byte address
11
+ */
12
+
13
+ import { secp256k1 } from '@noble/curves/secp256k1';
14
+ import { keccak_256 } from '@noble/hashes/sha3';
15
+ import { bytesToHex } from '@noble/hashes/utils';
16
+ import type { StealthMetaAddress, StealthAddressResult } from './types.js';
17
+ import { StealthAddressError, InvalidKeyError } from './errors.js';
18
+
19
+ /**
20
+ * Convert a 64-byte uncompressed public key (no 0x04 prefix) to an EVM address.
21
+ * address = last 20 bytes of keccak256(pubkey64)
22
+ */
23
+ function pubKeyToAddress(uncompressed64: Uint8Array): `0x${string}` {
24
+ const hash = keccak_256(uncompressed64);
25
+ const addrBytes = hash.slice(12);
26
+ return `0x${bytesToHex(addrBytes)}` as `0x${string}`;
27
+ }
28
+
29
+ /**
30
+ * Generate a one-time stealth address for a recipient.
31
+ *
32
+ * @param meta - Recipient's stealth meta-address (two compressed public keys)
33
+ * @returns StealthAddressResult with stealth address, ephemeral pubkey, and view tag
34
+ * @throws InvalidKeyError if meta-address public keys are invalid
35
+ * @throws StealthAddressError if the derived stealth point is at infinity
36
+ */
37
+ export function generateStealthAddress(
38
+ meta: StealthMetaAddress,
39
+ ): StealthAddressResult {
40
+ // Validate input public keys by parsing them (fromHex validates on-curve)
41
+ let viewingPoint: InstanceType<typeof secp256k1.ProjectivePoint>;
42
+ let spendingPoint: InstanceType<typeof secp256k1.ProjectivePoint>;
43
+ try {
44
+ viewingPoint = secp256k1.ProjectivePoint.fromHex(meta.viewingPubKey);
45
+ } catch {
46
+ throw new InvalidKeyError('Invalid viewing public key in meta-address');
47
+ }
48
+ try {
49
+ spendingPoint = secp256k1.ProjectivePoint.fromHex(meta.spendingPubKey);
50
+ } catch {
51
+ throw new InvalidKeyError('Invalid spending public key in meta-address');
52
+ }
53
+
54
+ // Step 1: Generate ephemeral keypair
55
+ const ephemeralPriv = secp256k1.utils.randomPrivateKey();
56
+ const ephemeralPub = secp256k1.getPublicKey(ephemeralPriv, true); // compressed
57
+
58
+ try {
59
+ // Step 2: ECDH shared secret S = r * P_view
60
+ const ephemeralScalar = bytesToBigInt(ephemeralPriv);
61
+ const sharedSecretPoint = viewingPoint.multiply(ephemeralScalar);
62
+
63
+ // Step 3: Hash the compressed shared secret
64
+ const sharedSecretCompressed = sharedSecretPoint.toRawBytes(true); // 33 bytes
65
+ const hashedSecret = keccak_256(sharedSecretCompressed); // 32 bytes
66
+
67
+ // Step 4: View tag = first byte
68
+ const viewTag = hashedSecret[0]!;
69
+
70
+ // Step 5: Stealth public key = P_spend + hashedSecret * G
71
+ const hashedScalar = bytesToBigInt(hashedSecret) % secp256k1.CURVE.n;
72
+ if (hashedScalar === 0n) {
73
+ throw new StealthAddressError('Hashed secret reduced to zero');
74
+ }
75
+ const hashedPoint = secp256k1.ProjectivePoint.BASE.multiply(hashedScalar);
76
+ const stealthPubPoint = spendingPoint.add(hashedPoint);
77
+
78
+ // Verify not point at infinity
79
+ try {
80
+ stealthPubPoint.assertValidity();
81
+ } catch {
82
+ throw new StealthAddressError('Stealth public key is the point at infinity');
83
+ }
84
+
85
+ // Step 6: Derive EVM address from uncompressed stealth public key
86
+ const stealthPubUncompressed = stealthPubPoint.toRawBytes(false); // 65 bytes (04 || x || y)
87
+ const stealthAddress = pubKeyToAddress(stealthPubUncompressed.slice(1)); // strip 0x04 prefix
88
+
89
+ return {
90
+ stealthAddress,
91
+ ephemeralPubKey: ephemeralPub,
92
+ viewTag,
93
+ };
94
+ } finally {
95
+ // Zero ephemeral private key
96
+ ephemeralPriv.fill(0);
97
+ }
98
+ }
99
+
100
+ /** Convert a Uint8Array to a BigInt (big-endian). */
101
+ function bytesToBigInt(bytes: Uint8Array): bigint {
102
+ let result = 0n;
103
+ for (const byte of bytes) {
104
+ result = (result << 8n) | BigInt(byte);
105
+ }
106
+ return result;
107
+ }
package/src/types.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ShroudFi Core Types
3
+ *
4
+ * Branded key types for compile-time safety -- prevents accidentally
5
+ * passing a scanning key where a spending key is expected.
6
+ */
7
+
8
+ /** secp256k1 spending private key (32 bytes). Controls funds. */
9
+ export type SpendingKey = {
10
+ readonly __brand: 'SpendingKey';
11
+ readonly bytes: Uint8Array;
12
+ };
13
+
14
+ /** secp256k1 scanning private key (32 bytes). Detects incoming payments. */
15
+ export type ScanningKey = {
16
+ readonly __brand: 'ScanningKey';
17
+ readonly bytes: Uint8Array;
18
+ };
19
+
20
+ /** secp256k1 viewing key (32 bytes). Enables selective disclosure. */
21
+ export type ViewingKey = {
22
+ readonly __brand: 'ViewingKey';
23
+ readonly bytes: Uint8Array;
24
+ };
25
+
26
+ /**
27
+ * ERC-6538 stealth meta-address: two compressed secp256k1 public keys.
28
+ * Shareable -- contains NO private material.
29
+ */
30
+ export interface StealthMetaAddress {
31
+ readonly spendingPubKey: Uint8Array; // 33 bytes compressed
32
+ readonly viewingPubKey: Uint8Array; // 33 bytes compressed
33
+ readonly schemeId: number;
34
+ }
35
+
36
+ /** Result of generating a one-time stealth address for a recipient. */
37
+ export interface StealthAddressResult {
38
+ /** The derived stealth EVM address. */
39
+ readonly stealthAddress: `0x${string}`;
40
+ /** Compressed ephemeral public key (33 bytes) -- must be announced. */
41
+ readonly ephemeralPubKey: Uint8Array;
42
+ /** View tag (0-255) -- first byte of hashed shared secret. */
43
+ readonly viewTag: number;
44
+ }
45
+
46
+ /** Full key hierarchy derived from a master seed. */
47
+ export interface KeyHierarchy {
48
+ readonly spendingKey: SpendingKey;
49
+ readonly scanningKey: ScanningKey;
50
+ readonly viewingKey: ViewingKey;
51
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * View Tag Utilities
3
+ *
4
+ * View tags enable fast filtering of stealth address announcements.
5
+ * The view tag is the first byte of keccak256(compress(ECDH_shared_secret)).
6
+ * This filters ~99.6% of non-matching announcements with a single byte check.
7
+ */
8
+
9
+ import { secp256k1 } from '@noble/curves/secp256k1';
10
+ import { keccak_256 } from '@noble/hashes/sha3';
11
+ import type { ScanningKey } from './types.js';
12
+ import { InvalidKeyError } from './errors.js';
13
+
14
+ /**
15
+ * Extract the view tag from a compressed shared secret point.
16
+ * The view tag is byte 0 of keccak256(compressedPoint).
17
+ *
18
+ * @param sharedSecretCompressed - 33-byte compressed secp256k1 point
19
+ * @returns View tag value (0-255)
20
+ */
21
+ export function extractViewTag(sharedSecretCompressed: Uint8Array): number {
22
+ const hash = keccak_256(sharedSecretCompressed);
23
+ return hash[0]!;
24
+ }
25
+
26
+ /**
27
+ * Check whether a view tag matches for a given scanning key and ephemeral public key.
28
+ *
29
+ * Performs the ECDH step and view tag extraction, then compares against
30
+ * the expected tag. This is the fast-path filter for scanning.
31
+ *
32
+ * @param scanningKey - Recipient's scanning private key
33
+ * @param ephemeralPubKey - Ephemeral public key from the announcement (33 bytes)
34
+ * @param expectedTag - The view tag from the announcement metadata
35
+ * @returns true if the view tag matches (candidate for full verification)
36
+ */
37
+ export function checkViewTag(
38
+ scanningKey: ScanningKey,
39
+ ephemeralPubKey: Uint8Array,
40
+ expectedTag: number,
41
+ ): boolean {
42
+ // Validate ephemeral public key
43
+ let ephemeralPoint: InstanceType<typeof secp256k1.ProjectivePoint>;
44
+ try {
45
+ ephemeralPoint = secp256k1.ProjectivePoint.fromHex(ephemeralPubKey);
46
+ } catch {
47
+ throw new InvalidKeyError('Invalid ephemeral public key');
48
+ }
49
+
50
+ // ECDH: sharedSecret = scanningPriv * P_ephemeral
51
+ const scanScalar = bytesToBigInt(scanningKey.bytes);
52
+ const sharedSecretPoint = ephemeralPoint.multiply(scanScalar);
53
+
54
+ // Hash compressed shared secret and extract view tag
55
+ const sharedSecretCompressed = sharedSecretPoint.toRawBytes(true);
56
+ const tag = extractViewTag(sharedSecretCompressed);
57
+
58
+ return tag === expectedTag;
59
+ }
60
+
61
+ /** Convert a Uint8Array to a BigInt (big-endian). */
62
+ function bytesToBigInt(bytes: Uint8Array): bigint {
63
+ let result = 0n;
64
+ for (const byte of bytes) {
65
+ result = (result << 8n) | BigInt(byte);
66
+ }
67
+ return result;
68
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist/esm",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["dist", "test", "node_modules"]
9
+ }