@7h3/protocol 0.4.0 → 0.5.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/CHANGELOG.md +60 -0
- package/README.md +1169 -175
- package/bin/7h3.ts +22 -1
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/package.json +55 -13
- package/sdk/browser/package.json +1 -1
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +5 -1
- package/sdk/go/go.sum +4 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/pyproject.toml +1 -1
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/rust/Cargo.lock +1 -1
- package/sdk/rust/Cargo.toml +1 -1
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/gateway.ts +75 -0
- package/src/httpBinding.ts +37 -11
- package/src/index.ts +7 -0
- package/src/otel.ts +136 -0
- package/src/protocol.d.ts +67 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.ts +1 -0
- package/src/replayStores.test.ts +133 -1
- package/src/replayStores.ts +136 -3
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/wsBinding.ts +100 -0
- package/vitest.config.ts +11 -0
package/src/otel.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* otel.ts — Optional OpenTelemetry integration for 7h3 Protocol
|
|
3
|
+
*
|
|
4
|
+
* Pass your @opentelemetry/api Tracer provider via setOtelProvider.
|
|
5
|
+
* No hard dependency — works with any OTel-compatible SDK or without any OTel at all.
|
|
6
|
+
*
|
|
7
|
+
* Duck-typed interfaces: any object matching the shape will work, including
|
|
8
|
+
* the real @opentelemetry/api TracerProvider.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { setOtelProvider } from '@7h3/protocol/otel'
|
|
12
|
+
* import { trace } from '@opentelemetry/api'
|
|
13
|
+
*
|
|
14
|
+
* setOtelProvider(trace.getTracerProvider())
|
|
15
|
+
*
|
|
16
|
+
* Without OTel configured, withVerificationSpan and withAuditSpan call fn(null)
|
|
17
|
+
* with zero overhead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ─── Duck-typed OTel interfaces ───────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A minimal OTel-compatible span interface.
|
|
24
|
+
* Compatible with @opentelemetry/api Span.
|
|
25
|
+
*/
|
|
26
|
+
export interface OtelSpan {
|
|
27
|
+
setAttribute(key: string, value: string | number | boolean): void
|
|
28
|
+
end(): void
|
|
29
|
+
recordException(error: Error): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A minimal OTel-compatible tracer interface.
|
|
34
|
+
* Compatible with @opentelemetry/api Tracer.
|
|
35
|
+
*/
|
|
36
|
+
export interface OtelTracer {
|
|
37
|
+
startSpan(name: string, attrs?: Record<string, string | number>): OtelSpan
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A minimal OTel-compatible provider interface.
|
|
42
|
+
* Compatible with @opentelemetry/api TracerProvider.
|
|
43
|
+
*/
|
|
44
|
+
export interface OtelProvider {
|
|
45
|
+
getTracer(name: string): OtelTracer
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
let _provider: OtelProvider | null = null
|
|
51
|
+
|
|
52
|
+
// ─── Provider management ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register an OTel provider. Call once at application startup.
|
|
56
|
+
* Subsequent calls replace the previous provider.
|
|
57
|
+
*/
|
|
58
|
+
export function setOtelProvider(provider: OtelProvider): void {
|
|
59
|
+
_provider = provider
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns the active OTel tracer for '7h3/protocol', or null if no provider
|
|
64
|
+
* has been registered.
|
|
65
|
+
*/
|
|
66
|
+
export function getOtelTracer(): OtelTracer | null {
|
|
67
|
+
if (_provider === null) return null
|
|
68
|
+
try {
|
|
69
|
+
return _provider.getTracer('7h3/protocol')
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Span helpers ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a span named '7h3.verify' if OTel is configured, calls fn(span),
|
|
79
|
+
* records any exception, and ends the span on completion.
|
|
80
|
+
*
|
|
81
|
+
* If no OTel provider is registered, calls fn(null) directly with zero overhead.
|
|
82
|
+
*/
|
|
83
|
+
export async function withVerificationSpan<T>(
|
|
84
|
+
fn: (span: OtelSpan | null) => Promise<T>,
|
|
85
|
+
attrs?: Record<string, string | number>,
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
const tracer = getOtelTracer()
|
|
88
|
+
if (tracer === null) {
|
|
89
|
+
return fn(null)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const span = tracer.startSpan('7h3.verify', attrs)
|
|
93
|
+
try {
|
|
94
|
+
const result = await fn(span)
|
|
95
|
+
return result
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof Error) {
|
|
98
|
+
span.recordException(err)
|
|
99
|
+
} else {
|
|
100
|
+
span.recordException(new Error(String(err)))
|
|
101
|
+
}
|
|
102
|
+
throw err
|
|
103
|
+
} finally {
|
|
104
|
+
span.end()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Creates a span named '7h3.audit.write' if OTel is configured, calls fn(span),
|
|
110
|
+
* records any exception, and ends the span on completion.
|
|
111
|
+
*
|
|
112
|
+
* If no OTel provider is registered, calls fn(null) directly with zero overhead.
|
|
113
|
+
*/
|
|
114
|
+
export async function withAuditSpan<T>(
|
|
115
|
+
fn: (span: OtelSpan | null) => Promise<T>,
|
|
116
|
+
): Promise<T> {
|
|
117
|
+
const tracer = getOtelTracer()
|
|
118
|
+
if (tracer === null) {
|
|
119
|
+
return fn(null)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const span = tracer.startSpan('7h3.audit.write')
|
|
123
|
+
try {
|
|
124
|
+
const result = await fn(span)
|
|
125
|
+
return result
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof Error) {
|
|
128
|
+
span.recordException(err)
|
|
129
|
+
} else {
|
|
130
|
+
span.recordException(new Error(String(err)))
|
|
131
|
+
}
|
|
132
|
+
throw err
|
|
133
|
+
} finally {
|
|
134
|
+
span.end()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type ProtocolVersion = '7h3/0.1';
|
|
2
|
+
export type IntentKind = 'PING' | 'PONG' | 'CAPS' | 'TASK' | 'RESULT' | 'ERROR';
|
|
3
|
+
export interface ProtocolHeader {
|
|
4
|
+
version: ProtocolVersion;
|
|
5
|
+
messageId: string;
|
|
6
|
+
timestampMs: number;
|
|
7
|
+
ttlMs: number;
|
|
8
|
+
sender: string;
|
|
9
|
+
recipient?: string;
|
|
10
|
+
nonce: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProtocolBody {
|
|
13
|
+
intent: IntentKind;
|
|
14
|
+
content: string;
|
|
15
|
+
capability?: string;
|
|
16
|
+
correlationId?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProtocolSignature {
|
|
19
|
+
alg: 'HS256' | 'ED25519';
|
|
20
|
+
keyId: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ProtocolEnvelope {
|
|
24
|
+
header: ProtocolHeader;
|
|
25
|
+
body: ProtocolBody;
|
|
26
|
+
signature?: ProtocolSignature;
|
|
27
|
+
}
|
|
28
|
+
export interface ProtocolDiagnostic {
|
|
29
|
+
level: 'error' | 'warning';
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
export type SignatureVerificationMaterial = {
|
|
33
|
+
alg: 'HS256';
|
|
34
|
+
secret: string;
|
|
35
|
+
} | {
|
|
36
|
+
alg: 'ED25519';
|
|
37
|
+
publicKey: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function canonicalizeEnvelope(envelope: Omit<ProtocolEnvelope, 'signature'>): string;
|
|
40
|
+
export declare function signCanonicalPayloadHmac(payload: string, secret: string): Promise<string>;
|
|
41
|
+
export declare function verifyCanonicalPayloadHmac(payload: string, signature: string, secret: string): Promise<boolean>;
|
|
42
|
+
export declare function generateEd25519KeypairBase64Url(): Promise<{
|
|
43
|
+
publicKey: string;
|
|
44
|
+
privateKey: string;
|
|
45
|
+
}>;
|
|
46
|
+
export declare function signCanonicalPayloadEd25519(payload: string, privateKeyPkcs8Base64Url: string): Promise<string>;
|
|
47
|
+
export declare function verifyCanonicalPayloadEd25519(payload: string, signature: string, publicKeySpkiBase64Url: string): Promise<boolean>;
|
|
48
|
+
export declare function signEnvelopeHmac(envelope: Omit<ProtocolEnvelope, 'signature'>, secret: string, keyId?: string): Promise<ProtocolEnvelope>;
|
|
49
|
+
export declare function verifyEnvelopeHmac(envelope: ProtocolEnvelope, secret: string): Promise<boolean>;
|
|
50
|
+
export declare function signEnvelopeEd25519(envelope: Omit<ProtocolEnvelope, 'signature'>, privateKeyPkcs8Base64Url: string, keyId?: string): Promise<ProtocolEnvelope>;
|
|
51
|
+
export declare function verifyEnvelopeEd25519(envelope: ProtocolEnvelope, publicKeySpkiBase64Url: string): Promise<boolean>;
|
|
52
|
+
export declare function verifyEnvelopeSignature(envelope: ProtocolEnvelope, material: SignatureVerificationMaterial): Promise<boolean>;
|
|
53
|
+
export declare function verifyCanonicalPayloadSignature(payload: string, signature: ProtocolSignature | undefined, material: SignatureVerificationMaterial): Promise<boolean>;
|
|
54
|
+
export declare function validateEnvelope(envelope: ProtocolEnvelope, nowMs?: number): ProtocolDiagnostic[];
|
|
55
|
+
export declare function createEnvelope(input: {
|
|
56
|
+
sender: string;
|
|
57
|
+
recipient?: string;
|
|
58
|
+
intent: IntentKind;
|
|
59
|
+
content: string;
|
|
60
|
+
capability?: string;
|
|
61
|
+
correlationId?: string;
|
|
62
|
+
ttlMs?: number;
|
|
63
|
+
messageId?: string;
|
|
64
|
+
nonce?: string;
|
|
65
|
+
nowMs?: number;
|
|
66
|
+
}): Omit<ProtocolEnvelope, 'signature'>;
|
|
67
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["protocol.ts"],"names":[],"mappings":"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"}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
const textEncoder = new TextEncoder();
|
|
2
|
+
const HMAC_KEY_CACHE_LIMIT = 256;
|
|
3
|
+
const hmacKeyCache = new Map();
|
|
4
|
+
const ED25519_KEY_CACHE_LIMIT = 256;
|
|
5
|
+
const ed25519PrivateKeyCache = new Map();
|
|
6
|
+
const ed25519PublicKeyCache = new Map();
|
|
7
|
+
function getBufferLike() {
|
|
8
|
+
const candidate = globalThis;
|
|
9
|
+
return candidate.Buffer ?? null;
|
|
10
|
+
}
|
|
11
|
+
function serializeHeaderCanonical(header) {
|
|
12
|
+
const parts = [
|
|
13
|
+
`"messageId":${JSON.stringify(header.messageId)}`,
|
|
14
|
+
`"nonce":${JSON.stringify(header.nonce)}`,
|
|
15
|
+
];
|
|
16
|
+
if (header.recipient !== undefined) {
|
|
17
|
+
parts.push(`"recipient":${JSON.stringify(header.recipient)}`);
|
|
18
|
+
}
|
|
19
|
+
parts.push(`"sender":${JSON.stringify(header.sender)}`);
|
|
20
|
+
parts.push(`"timestampMs":${header.timestampMs}`);
|
|
21
|
+
parts.push(`"ttlMs":${header.ttlMs}`);
|
|
22
|
+
parts.push(`"version":${JSON.stringify(header.version)}`);
|
|
23
|
+
return `{${parts.join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
function serializeBodyCanonical(body) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (body.capability !== undefined) {
|
|
28
|
+
parts.push(`"capability":${JSON.stringify(body.capability)}`);
|
|
29
|
+
}
|
|
30
|
+
parts.push(`"content":${JSON.stringify(body.content)}`);
|
|
31
|
+
if (body.correlationId !== undefined) {
|
|
32
|
+
parts.push(`"correlationId":${JSON.stringify(body.correlationId)}`);
|
|
33
|
+
}
|
|
34
|
+
parts.push(`"intent":${JSON.stringify(body.intent)}`);
|
|
35
|
+
return `{${parts.join(',')}}`;
|
|
36
|
+
}
|
|
37
|
+
function getCachedHmacKey(secret) {
|
|
38
|
+
const cacheKey = `hs256:${secret}`;
|
|
39
|
+
const cached = hmacKeyCache.get(cacheKey);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
if (hmacKeyCache.size >= HMAC_KEY_CACHE_LIMIT) {
|
|
43
|
+
hmacKeyCache.clear();
|
|
44
|
+
}
|
|
45
|
+
const subtle = requireCryptoSubtle();
|
|
46
|
+
const imported = subtle
|
|
47
|
+
.importKey('raw', textEncoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
|
|
48
|
+
.catch((error) => {
|
|
49
|
+
hmacKeyCache.delete(cacheKey);
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
hmacKeyCache.set(cacheKey, imported);
|
|
53
|
+
return imported;
|
|
54
|
+
}
|
|
55
|
+
function getCachedEd25519PrivateKey(privateKeyPkcs8Base64Url) {
|
|
56
|
+
const cacheKey = `ed25519:pkcs8:${privateKeyPkcs8Base64Url}`;
|
|
57
|
+
const cached = ed25519PrivateKeyCache.get(cacheKey);
|
|
58
|
+
if (cached)
|
|
59
|
+
return cached;
|
|
60
|
+
if (ed25519PrivateKeyCache.size >= ED25519_KEY_CACHE_LIMIT) {
|
|
61
|
+
ed25519PrivateKeyCache.clear();
|
|
62
|
+
}
|
|
63
|
+
const subtle = requireCryptoSubtle();
|
|
64
|
+
const imported = subtle
|
|
65
|
+
.importKey('pkcs8', toArrayBuffer(fromBase64Url(privateKeyPkcs8Base64Url)), { name: 'Ed25519' }, false, ['sign'])
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
ed25519PrivateKeyCache.delete(cacheKey);
|
|
68
|
+
throw error;
|
|
69
|
+
});
|
|
70
|
+
ed25519PrivateKeyCache.set(cacheKey, imported);
|
|
71
|
+
return imported;
|
|
72
|
+
}
|
|
73
|
+
function getCachedEd25519PublicKey(publicKeySpkiBase64Url) {
|
|
74
|
+
const cacheKey = `ed25519:spki:${publicKeySpkiBase64Url}`;
|
|
75
|
+
const cached = ed25519PublicKeyCache.get(cacheKey);
|
|
76
|
+
if (cached)
|
|
77
|
+
return cached;
|
|
78
|
+
if (ed25519PublicKeyCache.size >= ED25519_KEY_CACHE_LIMIT) {
|
|
79
|
+
ed25519PublicKeyCache.clear();
|
|
80
|
+
}
|
|
81
|
+
const subtle = requireCryptoSubtle();
|
|
82
|
+
const imported = subtle
|
|
83
|
+
.importKey('spki', toArrayBuffer(fromBase64Url(publicKeySpkiBase64Url)), { name: 'Ed25519' }, false, ['verify'])
|
|
84
|
+
.catch((error) => {
|
|
85
|
+
ed25519PublicKeyCache.delete(cacheKey);
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
ed25519PublicKeyCache.set(cacheKey, imported);
|
|
89
|
+
return imported;
|
|
90
|
+
}
|
|
91
|
+
function toBase64Url(bytes) {
|
|
92
|
+
const bufferLike = getBufferLike();
|
|
93
|
+
const base64 = bufferLike ? bufferLike.from(bytes).toString('base64') : btoa(String.fromCharCode(...bytes));
|
|
94
|
+
return base64
|
|
95
|
+
.replace(/\+/g, '-')
|
|
96
|
+
.replace(/\//g, '_')
|
|
97
|
+
.replace(/=+$/g, '');
|
|
98
|
+
}
|
|
99
|
+
function fromBase64Url(value) {
|
|
100
|
+
const padded = value
|
|
101
|
+
.replace(/-/g, '+')
|
|
102
|
+
.replace(/_/g, '/')
|
|
103
|
+
.padEnd(Math.ceil(value.length / 4) * 4, '=');
|
|
104
|
+
const bufferLike = getBufferLike();
|
|
105
|
+
if (bufferLike) {
|
|
106
|
+
return new Uint8Array(bufferLike.from(padded, 'base64'));
|
|
107
|
+
}
|
|
108
|
+
const binary = atob(padded);
|
|
109
|
+
const bytes = new Uint8Array(binary.length);
|
|
110
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
111
|
+
bytes[i] = binary.charCodeAt(i);
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
114
|
+
}
|
|
115
|
+
function toArrayBuffer(bytes) {
|
|
116
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
117
|
+
}
|
|
118
|
+
export function canonicalizeEnvelope(envelope) {
|
|
119
|
+
return `{"body":${serializeBodyCanonical(envelope.body)},"header":${serializeHeaderCanonical(envelope.header)}}`;
|
|
120
|
+
}
|
|
121
|
+
function requireCryptoSubtle() {
|
|
122
|
+
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
|
123
|
+
throw new Error('Web Crypto API is not available in this runtime');
|
|
124
|
+
}
|
|
125
|
+
return crypto.subtle;
|
|
126
|
+
}
|
|
127
|
+
async function hmacSign(payload, secret) {
|
|
128
|
+
const subtle = requireCryptoSubtle();
|
|
129
|
+
const key = await getCachedHmacKey(secret);
|
|
130
|
+
const signature = await subtle.sign('HMAC', key, textEncoder.encode(payload));
|
|
131
|
+
return toBase64Url(new Uint8Array(signature));
|
|
132
|
+
}
|
|
133
|
+
async function hmacVerify(payload, signature, secret) {
|
|
134
|
+
const subtle = requireCryptoSubtle();
|
|
135
|
+
const key = await getCachedHmacKey(secret);
|
|
136
|
+
const signatureBytes = fromBase64Url(signature);
|
|
137
|
+
return subtle.verify('HMAC', key, signatureBytes.buffer, textEncoder.encode(payload));
|
|
138
|
+
}
|
|
139
|
+
async function ed25519Sign(payload, privateKeyPkcs8Base64Url) {
|
|
140
|
+
const subtle = requireCryptoSubtle();
|
|
141
|
+
const key = await getCachedEd25519PrivateKey(privateKeyPkcs8Base64Url);
|
|
142
|
+
const signature = await subtle.sign('Ed25519', key, textEncoder.encode(payload));
|
|
143
|
+
return toBase64Url(new Uint8Array(signature));
|
|
144
|
+
}
|
|
145
|
+
async function ed25519Verify(payload, signature, publicKeySpkiBase64Url) {
|
|
146
|
+
const subtle = requireCryptoSubtle();
|
|
147
|
+
const key = await getCachedEd25519PublicKey(publicKeySpkiBase64Url);
|
|
148
|
+
const signatureBytes = fromBase64Url(signature);
|
|
149
|
+
return subtle.verify('Ed25519', key, signatureBytes.buffer, textEncoder.encode(payload));
|
|
150
|
+
}
|
|
151
|
+
export async function signCanonicalPayloadHmac(payload, secret) {
|
|
152
|
+
return hmacSign(payload, secret);
|
|
153
|
+
}
|
|
154
|
+
export async function verifyCanonicalPayloadHmac(payload, signature, secret) {
|
|
155
|
+
return hmacVerify(payload, signature, secret);
|
|
156
|
+
}
|
|
157
|
+
export async function generateEd25519KeypairBase64Url() {
|
|
158
|
+
const subtle = requireCryptoSubtle();
|
|
159
|
+
const pair = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
160
|
+
const privateKeyRaw = await subtle.exportKey('pkcs8', pair.privateKey);
|
|
161
|
+
const publicKeyRaw = await subtle.exportKey('spki', pair.publicKey);
|
|
162
|
+
return {
|
|
163
|
+
privateKey: toBase64Url(new Uint8Array(privateKeyRaw)),
|
|
164
|
+
publicKey: toBase64Url(new Uint8Array(publicKeyRaw)),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export async function signCanonicalPayloadEd25519(payload, privateKeyPkcs8Base64Url) {
|
|
168
|
+
return ed25519Sign(payload, privateKeyPkcs8Base64Url);
|
|
169
|
+
}
|
|
170
|
+
export async function verifyCanonicalPayloadEd25519(payload, signature, publicKeySpkiBase64Url) {
|
|
171
|
+
return ed25519Verify(payload, signature, publicKeySpkiBase64Url);
|
|
172
|
+
}
|
|
173
|
+
export async function signEnvelopeHmac(envelope, secret, keyId = 'local-dev-key') {
|
|
174
|
+
const payload = canonicalizeEnvelope(envelope);
|
|
175
|
+
const signature = await hmacSign(payload, secret);
|
|
176
|
+
return {
|
|
177
|
+
...envelope,
|
|
178
|
+
signature: {
|
|
179
|
+
alg: 'HS256',
|
|
180
|
+
keyId,
|
|
181
|
+
value: signature,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export async function verifyEnvelopeHmac(envelope, secret) {
|
|
186
|
+
if (!envelope.signature)
|
|
187
|
+
return false;
|
|
188
|
+
if (envelope.signature.alg !== 'HS256')
|
|
189
|
+
return false;
|
|
190
|
+
const unsigned = {
|
|
191
|
+
header: envelope.header,
|
|
192
|
+
body: envelope.body,
|
|
193
|
+
};
|
|
194
|
+
const payload = canonicalizeEnvelope(unsigned);
|
|
195
|
+
return hmacVerify(payload, envelope.signature.value, secret);
|
|
196
|
+
}
|
|
197
|
+
export async function signEnvelopeEd25519(envelope, privateKeyPkcs8Base64Url, keyId = 'local-ed25519-key') {
|
|
198
|
+
const payload = canonicalizeEnvelope(envelope);
|
|
199
|
+
const signature = await ed25519Sign(payload, privateKeyPkcs8Base64Url);
|
|
200
|
+
return {
|
|
201
|
+
...envelope,
|
|
202
|
+
signature: {
|
|
203
|
+
alg: 'ED25519',
|
|
204
|
+
keyId,
|
|
205
|
+
value: signature,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export async function verifyEnvelopeEd25519(envelope, publicKeySpkiBase64Url) {
|
|
210
|
+
if (!envelope.signature)
|
|
211
|
+
return false;
|
|
212
|
+
if (envelope.signature.alg !== 'ED25519')
|
|
213
|
+
return false;
|
|
214
|
+
const unsigned = {
|
|
215
|
+
header: envelope.header,
|
|
216
|
+
body: envelope.body,
|
|
217
|
+
};
|
|
218
|
+
const payload = canonicalizeEnvelope(unsigned);
|
|
219
|
+
return ed25519Verify(payload, envelope.signature.value, publicKeySpkiBase64Url);
|
|
220
|
+
}
|
|
221
|
+
export async function verifyEnvelopeSignature(envelope, material) {
|
|
222
|
+
if (!envelope.signature)
|
|
223
|
+
return false;
|
|
224
|
+
if (envelope.signature.alg !== material.alg)
|
|
225
|
+
return false;
|
|
226
|
+
if (material.alg === 'HS256') {
|
|
227
|
+
return verifyEnvelopeHmac(envelope, material.secret);
|
|
228
|
+
}
|
|
229
|
+
return verifyEnvelopeEd25519(envelope, material.publicKey);
|
|
230
|
+
}
|
|
231
|
+
export async function verifyCanonicalPayloadSignature(payload, signature, material) {
|
|
232
|
+
if (!signature)
|
|
233
|
+
return false;
|
|
234
|
+
if (signature.alg !== material.alg)
|
|
235
|
+
return false;
|
|
236
|
+
if (material.alg === 'HS256') {
|
|
237
|
+
return verifyCanonicalPayloadHmac(payload, signature.value, material.secret);
|
|
238
|
+
}
|
|
239
|
+
return verifyCanonicalPayloadEd25519(payload, signature.value, material.publicKey);
|
|
240
|
+
}
|
|
241
|
+
export function validateEnvelope(envelope, nowMs = Date.now()) {
|
|
242
|
+
const diagnostics = [];
|
|
243
|
+
const header = envelope.header ?? {};
|
|
244
|
+
const body = envelope.body ?? {};
|
|
245
|
+
const version = typeof header.version === 'string' ? header.version : '';
|
|
246
|
+
const messageId = typeof header.messageId === 'string' ? header.messageId : '';
|
|
247
|
+
const sender = typeof header.sender === 'string' ? header.sender : '';
|
|
248
|
+
const nonce = typeof header.nonce === 'string' ? header.nonce : '';
|
|
249
|
+
const timestampMs = typeof header.timestampMs === 'number' ? header.timestampMs : 0;
|
|
250
|
+
const ttlMs = typeof header.ttlMs === 'number' ? header.ttlMs : 0;
|
|
251
|
+
const content = typeof body.content === 'string' ? body.content : '';
|
|
252
|
+
if (version !== '7h3/0.1') {
|
|
253
|
+
diagnostics.push({ level: 'error', message: `Unsupported protocol version '${version}'` });
|
|
254
|
+
}
|
|
255
|
+
if (!messageId.trim()) {
|
|
256
|
+
diagnostics.push({ level: 'error', message: 'Missing messageId' });
|
|
257
|
+
}
|
|
258
|
+
if (!sender.trim()) {
|
|
259
|
+
diagnostics.push({ level: 'error', message: 'Missing sender identity' });
|
|
260
|
+
}
|
|
261
|
+
if (!nonce.trim()) {
|
|
262
|
+
diagnostics.push({ level: 'error', message: 'Missing nonce — replay protection requires a unique nonce per message' });
|
|
263
|
+
}
|
|
264
|
+
if (ttlMs <= 0) {
|
|
265
|
+
diagnostics.push({ level: 'error', message: 'ttlMs must be greater than zero' });
|
|
266
|
+
}
|
|
267
|
+
if (timestampMs + ttlMs < nowMs) {
|
|
268
|
+
diagnostics.push({ level: 'error', message: 'Message TTL expired' });
|
|
269
|
+
}
|
|
270
|
+
if (!content.trim()) {
|
|
271
|
+
diagnostics.push({ level: 'warning', message: 'Empty content payload' });
|
|
272
|
+
}
|
|
273
|
+
return diagnostics;
|
|
274
|
+
}
|
|
275
|
+
export function createEnvelope(input) {
|
|
276
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
277
|
+
return {
|
|
278
|
+
header: {
|
|
279
|
+
version: '7h3/0.1',
|
|
280
|
+
messageId: input.messageId ?? `msg-${nowMs}-${Math.random().toString(36).slice(2, 10)}`,
|
|
281
|
+
timestampMs: nowMs,
|
|
282
|
+
ttlMs: input.ttlMs ?? 60_000,
|
|
283
|
+
sender: input.sender,
|
|
284
|
+
recipient: input.recipient,
|
|
285
|
+
nonce: input.nonce ?? Math.random().toString(36).slice(2, 12),
|
|
286
|
+
},
|
|
287
|
+
body: {
|
|
288
|
+
intent: input.intent,
|
|
289
|
+
content: input.content,
|
|
290
|
+
capability: input.capability,
|
|
291
|
+
correlationId: input.correlationId,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
package/src/protocol.ts
CHANGED
package/src/replayStores.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { InMemoryRedisLikeClient } from './redisClient'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createRedisReplayStore,
|
|
5
|
+
RedisReplayStore,
|
|
6
|
+
ClusterRedisReplayStore,
|
|
7
|
+
createClusterReplayStore,
|
|
8
|
+
type ReplayStore,
|
|
9
|
+
type RedisClientLike,
|
|
10
|
+
} from './replayStores'
|
|
4
11
|
import { DistributedReplayCache } from './protocolReplay'
|
|
5
12
|
import { createEnvelope } from './protocol'
|
|
6
13
|
|
|
@@ -139,3 +146,128 @@ describe('DistributedReplayCache batch delegation', () => {
|
|
|
139
146
|
expect(results.map((r) => r.ok)).toEqual([true, true])
|
|
140
147
|
})
|
|
141
148
|
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// New ReplayStore interface and class tests (check()-based API)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build a mock RedisClientLike whose SET NX behaviour can be scripted:
|
|
156
|
+
* first call returns 'OK' (fresh), subsequent calls return null (replay).
|
|
157
|
+
*/
|
|
158
|
+
function mockRedisClient(): RedisClientLike {
|
|
159
|
+
let callCount = 0
|
|
160
|
+
return {
|
|
161
|
+
set: vi.fn(async (_key: string, _value: string, _opts?: { nx?: boolean; px?: number }) => {
|
|
162
|
+
callCount++
|
|
163
|
+
return callCount === 1 ? 'OK' : null
|
|
164
|
+
}),
|
|
165
|
+
get: vi.fn(async (_key: string) => null),
|
|
166
|
+
del: vi.fn(async (_key: string) => 0),
|
|
167
|
+
quit: vi.fn(async () => undefined),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('RedisReplayStore (check() interface)', () => {
|
|
172
|
+
it('first call returns false (fresh) and second call returns true (replay)', async () => {
|
|
173
|
+
const client = mockRedisClient()
|
|
174
|
+
const store = new RedisReplayStore({ client })
|
|
175
|
+
const fresh = await store.check('nonce-abc', 30_000)
|
|
176
|
+
const replay = await store.check('nonce-abc', 30_000)
|
|
177
|
+
expect(fresh).toBe(false)
|
|
178
|
+
expect(replay).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('satisfies the ReplayStore interface', () => {
|
|
182
|
+
const store: ReplayStore = new RedisReplayStore({ client: mockRedisClient() })
|
|
183
|
+
expect(typeof store.check).toBe('function')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('uses default keyPrefix "7h3:nonce:"', async () => {
|
|
187
|
+
const client = mockRedisClient()
|
|
188
|
+
const store = new RedisReplayStore({ client })
|
|
189
|
+
await store.check('my-nonce', 5000)
|
|
190
|
+
expect(client.set).toHaveBeenCalledWith('7h3:nonce:my-nonce', '1', { nx: true, px: 5000 })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('respects a custom keyPrefix', async () => {
|
|
194
|
+
const client = mockRedisClient()
|
|
195
|
+
const store = new RedisReplayStore({ client, keyPrefix: 'custom:' })
|
|
196
|
+
await store.check('n1', 1000)
|
|
197
|
+
expect(client.set).toHaveBeenCalledWith('custom:n1', '1', { nx: true, px: 1000 })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('clamps TTL to at least 1ms', async () => {
|
|
201
|
+
const client = mockRedisClient()
|
|
202
|
+
const store = new RedisReplayStore({ client })
|
|
203
|
+
await store.check('n1', 0)
|
|
204
|
+
expect(client.set).toHaveBeenCalledWith('7h3:nonce:n1', '1', { nx: true, px: 1 })
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('createRedisReplayStore opts-object overload', () => {
|
|
209
|
+
it('returns a RedisReplayStore with the correct keyPrefix', () => {
|
|
210
|
+
const store = createRedisReplayStore({ keyPrefix: '7h3:nonce:' })
|
|
211
|
+
expect(store).toBeInstanceOf(RedisReplayStore)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('returns a RedisReplayStore with default keyPrefix when none specified', () => {
|
|
215
|
+
const store = createRedisReplayStore({ client: mockRedisClient() })
|
|
216
|
+
expect(store).toBeInstanceOf(RedisReplayStore)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('ClusterRedisReplayStore', () => {
|
|
221
|
+
it('check() returns false (fresh) when ALL nodes say fresh', async () => {
|
|
222
|
+
// Build two nodes each with a fresh client
|
|
223
|
+
const makeClient = () => {
|
|
224
|
+
const c = mockRedisClient()
|
|
225
|
+
return c
|
|
226
|
+
}
|
|
227
|
+
const node1 = new RedisReplayStore({ client: makeClient() })
|
|
228
|
+
const node2 = new RedisReplayStore({ client: makeClient() })
|
|
229
|
+
const cluster = new ClusterRedisReplayStore([node1, node2])
|
|
230
|
+
// First call: both nodes return OK → fresh
|
|
231
|
+
const result = await cluster.check('nonce-xyz', 10_000)
|
|
232
|
+
expect(result).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('check() returns true (replay) when ANY node reports replay', async () => {
|
|
236
|
+
// node1: already seen (returns null immediately)
|
|
237
|
+
const replayClient: RedisClientLike = {
|
|
238
|
+
set: vi.fn(async () => null), // always null = always replay
|
|
239
|
+
get: vi.fn(async () => null),
|
|
240
|
+
del: vi.fn(async () => 0),
|
|
241
|
+
quit: vi.fn(async () => undefined),
|
|
242
|
+
}
|
|
243
|
+
// node2: fresh (returns OK)
|
|
244
|
+
const freshClient: RedisClientLike = {
|
|
245
|
+
set: vi.fn(async () => 'OK'),
|
|
246
|
+
get: vi.fn(async () => null),
|
|
247
|
+
del: vi.fn(async () => 0),
|
|
248
|
+
quit: vi.fn(async () => undefined),
|
|
249
|
+
}
|
|
250
|
+
const node1 = new RedisReplayStore({ client: replayClient })
|
|
251
|
+
const node2 = new RedisReplayStore({ client: freshClient })
|
|
252
|
+
const cluster = new ClusterRedisReplayStore([node1, node2])
|
|
253
|
+
const result = await cluster.check('nonce-xyz', 10_000)
|
|
254
|
+
expect(result).toBe(true)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('queries all nodes in parallel via Promise.all', async () => {
|
|
258
|
+
const clients = [mockRedisClient(), mockRedisClient(), mockRedisClient()]
|
|
259
|
+
const nodes = clients.map((c) => new RedisReplayStore({ client: c }))
|
|
260
|
+
const cluster = new ClusterRedisReplayStore(nodes)
|
|
261
|
+
await cluster.check('nonce-parallel', 5000)
|
|
262
|
+
for (const c of clients) {
|
|
263
|
+
expect(c.set).toHaveBeenCalledOnce()
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('createClusterReplayStore', () => {
|
|
269
|
+
it('returns a ClusterRedisReplayStore from an array of URLs', () => {
|
|
270
|
+
const cluster = createClusterReplayStore(['redis://node1:6379', 'redis://node2:6379'])
|
|
271
|
+
expect(cluster).toBeInstanceOf(ClusterRedisReplayStore)
|
|
272
|
+
})
|
|
273
|
+
})
|