@totemsdk/manifest 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/README.md +223 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +13 -0
- package/dist/encoding.d.ts +17 -0
- package/dist/encoding.js +83 -0
- package/dist/guards.d.ts +11 -0
- package/dist/guards.js +26 -0
- package/dist/id.d.ts +14 -0
- package/dist/id.js +40 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/sign.d.ts +19 -0
- package/dist/sign.js +48 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.js +12 -0
- package/dist/verify.d.ts +15 -0
- package/dist/verify.js +59 -0
- package/package.json +49 -0
- package/src/__tests__/encoding.test.ts +185 -0
- package/src/__tests__/guards.test.ts +140 -0
- package/src/__tests__/id.test.ts +143 -0
- package/src/__tests__/sign-verify.test.ts +157 -0
- package/src/constants.ts +15 -0
- package/src/encoding.ts +100 -0
- package/src/guards.ts +48 -0
- package/src/id.ts +46 -0
- package/src/index.ts +26 -0
- package/src/sign.ts +63 -0
- package/src/types.ts +140 -0
- package/src/verify.ts +67 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sign-verify.test.ts
|
|
3
|
+
* Round-trip sign → verify for all four manifest types.
|
|
4
|
+
* Also tests tamper detection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { signManifest } from '../sign.js';
|
|
8
|
+
import { verifyManifest } from '../verify.js';
|
|
9
|
+
import type {
|
|
10
|
+
AppManifest,
|
|
11
|
+
CapabilityManifest,
|
|
12
|
+
DAppManifest,
|
|
13
|
+
EdgeServiceManifest,
|
|
14
|
+
} from '../types.js';
|
|
15
|
+
import { computeManifestId } from '../id.js';
|
|
16
|
+
|
|
17
|
+
jest.setTimeout(60_000);
|
|
18
|
+
|
|
19
|
+
const TEST_SEED = new Uint8Array(32).fill(0x42);
|
|
20
|
+
const KEY_INDEX = 0;
|
|
21
|
+
|
|
22
|
+
const APP: AppManifest = {
|
|
23
|
+
type: 'app',
|
|
24
|
+
appId: '',
|
|
25
|
+
name: 'Test App',
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
authorAddress: 'MxAUTHOR0000000000000000000000000000000000000000',
|
|
28
|
+
pearTopicKey: 'a'.repeat(64),
|
|
29
|
+
price: '0',
|
|
30
|
+
category: ['finance'],
|
|
31
|
+
permissions: ['wallet:read-balance'],
|
|
32
|
+
description: 'A test app',
|
|
33
|
+
minTotemVersion: '0.1.0',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const CAPABILITY: CapabilityManifest = {
|
|
37
|
+
type: 'capability',
|
|
38
|
+
capabilityId: '',
|
|
39
|
+
capabilityName: 'invoice-translate',
|
|
40
|
+
agentAddress: 'MxAGENT0000000000000000000000000000000000000000',
|
|
41
|
+
agentIdentityKey: 'b'.repeat(64),
|
|
42
|
+
description: 'Translates invoices',
|
|
43
|
+
inputSchema: { type: 'object' },
|
|
44
|
+
outputSchema: { type: 'object' },
|
|
45
|
+
pricePerCall: '1',
|
|
46
|
+
expiresAt: Date.now() + 86_400_000,
|
|
47
|
+
tags: ['finance'],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DAPP: DAppManifest = {
|
|
51
|
+
type: 'dapp',
|
|
52
|
+
dappId: '',
|
|
53
|
+
name: 'Test dApp',
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
authorAddress: 'MxAUTHOR0000000000000000000000000000000000000000',
|
|
56
|
+
contractHash: 'c'.repeat(64),
|
|
57
|
+
abi: [{ name: 'transfer', description: 'Transfer tokens', params: [{ name: 'amount', type: 'string' }] }],
|
|
58
|
+
price: '0',
|
|
59
|
+
category: ['defi'],
|
|
60
|
+
description: 'A test dApp',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const EDGE: EdgeServiceManifest = {
|
|
64
|
+
type: 'edge-service',
|
|
65
|
+
serviceId: '',
|
|
66
|
+
name: 'Test Sensor',
|
|
67
|
+
version: '1.0.0',
|
|
68
|
+
operatorAddress: 'MxOPERATOR000000000000000000000000000000000000',
|
|
69
|
+
serviceType: 'sensor',
|
|
70
|
+
description: 'A test sensor node',
|
|
71
|
+
capabilities: ['temperature'],
|
|
72
|
+
tags: ['iot'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function withId<T extends { type: string }>(m: T): T {
|
|
76
|
+
return m;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('signManifest + verifyManifest round-trips', () => {
|
|
80
|
+
let signedAddress: string;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
const tmp = await signManifest(APP, TEST_SEED, KEY_INDEX);
|
|
84
|
+
signedAddress = tmp.authorAddress;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('signs and verifies AppManifest', async () => {
|
|
88
|
+
const app = { ...APP, authorAddress: signedAddress };
|
|
89
|
+
const signed = await signManifest(app, TEST_SEED, KEY_INDEX);
|
|
90
|
+
expect(signed.manifest.type).toBe('app');
|
|
91
|
+
expect(signed.authorAddress).toBe(signedAddress);
|
|
92
|
+
expect(signed.signature).toBeTruthy();
|
|
93
|
+
expect(signed.signerPublicKey).toBeTruthy();
|
|
94
|
+
|
|
95
|
+
const result = verifyManifest(signed);
|
|
96
|
+
expect(result.valid).toBe(true);
|
|
97
|
+
expect(result.signerAddress).toBe(signedAddress);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('signs and verifies CapabilityManifest', async () => {
|
|
101
|
+
const cap = { ...CAPABILITY, agentAddress: signedAddress };
|
|
102
|
+
const signed = await signManifest(cap, TEST_SEED, KEY_INDEX);
|
|
103
|
+
expect(signed.manifest.type).toBe('capability');
|
|
104
|
+
|
|
105
|
+
const result = verifyManifest(signed);
|
|
106
|
+
expect(result.valid).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('signs and verifies DAppManifest', async () => {
|
|
110
|
+
const dapp = { ...DAPP, authorAddress: signedAddress };
|
|
111
|
+
const signed = await signManifest(dapp, TEST_SEED, KEY_INDEX);
|
|
112
|
+
expect(signed.manifest.type).toBe('dapp');
|
|
113
|
+
|
|
114
|
+
const result = verifyManifest(signed);
|
|
115
|
+
expect(result.valid).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('signs and verifies EdgeServiceManifest', async () => {
|
|
119
|
+
const edge = { ...EDGE, operatorAddress: signedAddress };
|
|
120
|
+
const signed = await signManifest(edge, TEST_SEED, KEY_INDEX);
|
|
121
|
+
expect(signed.manifest.type).toBe('edge-service');
|
|
122
|
+
|
|
123
|
+
const result = verifyManifest(signed);
|
|
124
|
+
expect(result.valid).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('detects manifest tampering — changed name field', async () => {
|
|
128
|
+
const app = { ...APP, authorAddress: signedAddress };
|
|
129
|
+
const signed = await signManifest(app, TEST_SEED, KEY_INDEX);
|
|
130
|
+
|
|
131
|
+
const tampered = {
|
|
132
|
+
...signed,
|
|
133
|
+
manifest: { ...signed.manifest, name: 'EVIL APP' } as AppManifest,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = verifyManifest(tampered);
|
|
137
|
+
expect(result.valid).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('detects tampered authorAddress in SignedManifest wrapper', async () => {
|
|
141
|
+
const app = { ...APP, authorAddress: signedAddress };
|
|
142
|
+
const signed = await signManifest(app, TEST_SEED, KEY_INDEX);
|
|
143
|
+
|
|
144
|
+
const tampered = { ...signed, authorAddress: 'Mx999999999999999999' };
|
|
145
|
+
const result = verifyManifest(tampered);
|
|
146
|
+
expect(result.valid).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('detects corrupted signature bytes', async () => {
|
|
150
|
+
const app = { ...APP, authorAddress: signedAddress };
|
|
151
|
+
const signed = await signManifest(app, TEST_SEED, KEY_INDEX);
|
|
152
|
+
|
|
153
|
+
const corrupted = { ...signed, signature: 'ff' + signed.signature.slice(2) };
|
|
154
|
+
const result = verifyManifest(corrupted);
|
|
155
|
+
expect(result.valid).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const MANIFEST_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
export const MANIFEST_TYPE_BYTE = {
|
|
4
|
+
app: 0x01,
|
|
5
|
+
capability: 0x02,
|
|
6
|
+
dapp: 0x03,
|
|
7
|
+
'edge-service': 0x04,
|
|
8
|
+
} as const satisfies Record<string, number>;
|
|
9
|
+
|
|
10
|
+
export const MANIFEST_BYTE_TO_TYPE: Record<number, string> = {
|
|
11
|
+
0x01: 'app',
|
|
12
|
+
0x02: 'capability',
|
|
13
|
+
0x03: 'dapp',
|
|
14
|
+
0x04: 'edge-service',
|
|
15
|
+
};
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire encoding/decoding for SignedManifest.
|
|
3
|
+
*
|
|
4
|
+
* Format:
|
|
5
|
+
* [1 byte MANIFEST_VERSION ]
|
|
6
|
+
* [1 byte type discriminant] 0x01=app 0x02=capability 0x03=dapp 0x04=edge-service
|
|
7
|
+
* [4 bytes big-endian JSON length]
|
|
8
|
+
* [N bytes canonical JSON (UTF-8)]
|
|
9
|
+
* [remaining bytes = WOTS signature]
|
|
10
|
+
*
|
|
11
|
+
* The JSON payload is the full SignedManifest object (manifest + metadata + signature).
|
|
12
|
+
* The trailing WOTS signature bytes are stored separately for easy extraction by
|
|
13
|
+
* the DHT / lookup-protocol layer without needing to parse the JSON.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { MANIFEST_VERSION, MANIFEST_TYPE_BYTE, MANIFEST_BYTE_TO_TYPE } from './constants.js';
|
|
17
|
+
import type { SignedManifest, Manifest } from './types.js';
|
|
18
|
+
|
|
19
|
+
function manifestTypeByte(type: Manifest['type']): number {
|
|
20
|
+
const byte = MANIFEST_TYPE_BYTE[type];
|
|
21
|
+
if (byte === undefined) {
|
|
22
|
+
throw new Error(`encodeManifest: unknown manifest type: ${type}`);
|
|
23
|
+
}
|
|
24
|
+
return byte;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encodeManifest(signed: SignedManifest): Uint8Array {
|
|
28
|
+
const enc = new TextEncoder();
|
|
29
|
+
const json = JSON.stringify(signed);
|
|
30
|
+
const jsonBytes = enc.encode(json);
|
|
31
|
+
const sigBytes = hexToBytes(signed.signature);
|
|
32
|
+
|
|
33
|
+
const len = jsonBytes.length;
|
|
34
|
+
const out = new Uint8Array(1 + 1 + 4 + len + sigBytes.length);
|
|
35
|
+
let offset = 0;
|
|
36
|
+
|
|
37
|
+
out[offset++] = MANIFEST_VERSION;
|
|
38
|
+
out[offset++] = manifestTypeByte(signed.manifest.type);
|
|
39
|
+
out[offset++] = (len >>> 24) & 0xff;
|
|
40
|
+
out[offset++] = (len >>> 16) & 0xff;
|
|
41
|
+
out[offset++] = (len >>> 8) & 0xff;
|
|
42
|
+
out[offset++] = len & 0xff;
|
|
43
|
+
out.set(jsonBytes, offset);
|
|
44
|
+
offset += len;
|
|
45
|
+
out.set(sigBytes, offset);
|
|
46
|
+
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decodeManifest(bytes: Uint8Array): SignedManifest {
|
|
51
|
+
if (bytes.length < 6) {
|
|
52
|
+
throw new Error('decodeManifest: buffer too short (< 6 bytes)');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const version = bytes[0];
|
|
56
|
+
if (version !== MANIFEST_VERSION) {
|
|
57
|
+
throw new Error(`decodeManifest: unsupported MANIFEST_VERSION ${version}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const typeByte = bytes[1];
|
|
61
|
+
const expectedType = MANIFEST_BYTE_TO_TYPE[typeByte];
|
|
62
|
+
if (!expectedType) {
|
|
63
|
+
throw new Error(`decodeManifest: unknown type discriminant 0x${typeByte.toString(16).padStart(2, '0')}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const jsonLen =
|
|
67
|
+
(bytes[2] << 24) | (bytes[3] << 16) | (bytes[4] << 8) | bytes[5];
|
|
68
|
+
|
|
69
|
+
if (bytes.length < 6 + jsonLen) {
|
|
70
|
+
throw new Error(`decodeManifest: buffer too short for declared JSON length ${jsonLen}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const jsonBytes = bytes.slice(6, 6 + jsonLen);
|
|
74
|
+
const json = new TextDecoder().decode(jsonBytes);
|
|
75
|
+
|
|
76
|
+
let signed: SignedManifest;
|
|
77
|
+
try {
|
|
78
|
+
signed = JSON.parse(json) as SignedManifest;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw new Error(`decodeManifest: invalid JSON payload: ${String(e)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (signed.manifest?.type !== expectedType) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`decodeManifest: type discriminant mismatch — wire says '${expectedType}' but JSON manifest.type is '${signed.manifest?.type}'`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return signed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
93
|
+
if (hex.startsWith('0x')) hex = hex.slice(2);
|
|
94
|
+
if (hex.length % 2 !== 0) throw new Error('hexToBytes: odd-length hex string');
|
|
95
|
+
const out = new Uint8Array(hex.length / 2);
|
|
96
|
+
for (let i = 0; i < out.length; i++) {
|
|
97
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guards for manifest kinds.
|
|
3
|
+
* Each guard accepts a raw Manifest or a SignedManifest.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Manifest,
|
|
8
|
+
SignedManifest,
|
|
9
|
+
AppManifest,
|
|
10
|
+
CapabilityManifest,
|
|
11
|
+
DAppManifest,
|
|
12
|
+
EdgeServiceManifest,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
type MaybeSignedOrRaw = Manifest | SignedManifest | null | undefined;
|
|
16
|
+
|
|
17
|
+
function getRawManifest(input: MaybeSignedOrRaw): Manifest | null {
|
|
18
|
+
if (!input || typeof input !== 'object') return null;
|
|
19
|
+
if ('manifest' in input && input.manifest && typeof input.manifest === 'object') {
|
|
20
|
+
return (input as SignedManifest).manifest;
|
|
21
|
+
}
|
|
22
|
+
if ('type' in input) return input as Manifest;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isAppManifest(
|
|
27
|
+
input: MaybeSignedOrRaw,
|
|
28
|
+
): input is AppManifest | SignedManifest<AppManifest> {
|
|
29
|
+
return getRawManifest(input)?.type === 'app';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCapabilityManifest(
|
|
33
|
+
input: MaybeSignedOrRaw,
|
|
34
|
+
): input is CapabilityManifest | SignedManifest<CapabilityManifest> {
|
|
35
|
+
return getRawManifest(input)?.type === 'capability';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isDAppManifest(
|
|
39
|
+
input: MaybeSignedOrRaw,
|
|
40
|
+
): input is DAppManifest | SignedManifest<DAppManifest> {
|
|
41
|
+
return getRawManifest(input)?.type === 'dapp';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isEdgeServiceManifest(
|
|
45
|
+
input: MaybeSignedOrRaw,
|
|
46
|
+
): input is EdgeServiceManifest | SignedManifest<EdgeServiceManifest> {
|
|
47
|
+
return getRawManifest(input)?.type === 'edge-service';
|
|
48
|
+
}
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computeManifestId — deterministic stable ID for any manifest.
|
|
3
|
+
*
|
|
4
|
+
* The ID is SHA3-256 over a type-specific stable key string.
|
|
5
|
+
* It must be stable across version bumps (version is NOT part of the input).
|
|
6
|
+
*
|
|
7
|
+
* Stable key rules:
|
|
8
|
+
* AppManifest → "app" + authorAddress + pearTopicKey
|
|
9
|
+
* CapabilityManifest → "capability" + agentAddress + capabilityName
|
|
10
|
+
* DAppManifest → "dapp" + authorAddress + contractHash
|
|
11
|
+
* EdgeServiceManifest → "edge-service" + operatorAddress + serviceType + name
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { sha3_256 } from '@noble/hashes/sha3.js';
|
|
15
|
+
import type { Manifest } from './types.js';
|
|
16
|
+
|
|
17
|
+
function bytesToHex(b: Uint8Array): string {
|
|
18
|
+
return Array.from(b)
|
|
19
|
+
.map((x) => x.toString(16).padStart(2, '0'))
|
|
20
|
+
.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function computeManifestId(manifest: Manifest): string {
|
|
24
|
+
let stableKey: string;
|
|
25
|
+
|
|
26
|
+
switch (manifest.type) {
|
|
27
|
+
case 'app':
|
|
28
|
+
stableKey = `app\0${manifest.authorAddress}\0${manifest.pearTopicKey}`;
|
|
29
|
+
break;
|
|
30
|
+
case 'capability':
|
|
31
|
+
stableKey = `capability\0${manifest.agentAddress}\0${manifest.capabilityName}`;
|
|
32
|
+
break;
|
|
33
|
+
case 'dapp':
|
|
34
|
+
stableKey = `dapp\0${manifest.authorAddress}\0${manifest.contractHash}`;
|
|
35
|
+
break;
|
|
36
|
+
case 'edge-service':
|
|
37
|
+
stableKey = `edge-service\0${manifest.operatorAddress}\0${manifest.serviceType}\0${manifest.name}`;
|
|
38
|
+
break;
|
|
39
|
+
default: {
|
|
40
|
+
const _exhaustive: never = manifest;
|
|
41
|
+
throw new Error(`computeManifestId: unknown manifest type: ${JSON.stringify(_exhaustive)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return bytesToHex(sha3_256(new TextEncoder().encode(stableKey)));
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AppManifest,
|
|
3
|
+
CapabilityManifest,
|
|
4
|
+
DAppManifest,
|
|
5
|
+
EdgeServiceManifest,
|
|
6
|
+
EdgeServiceType,
|
|
7
|
+
Manifest,
|
|
8
|
+
SignedManifest,
|
|
9
|
+
AppPermission,
|
|
10
|
+
DAppAbiEntry,
|
|
11
|
+
VerifyResult,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
export { MANIFEST_VERSION } from './constants.js';
|
|
15
|
+
|
|
16
|
+
export { computeManifestId } from './id.js';
|
|
17
|
+
export { signManifest } from './sign.js';
|
|
18
|
+
export { verifyManifest } from './verify.js';
|
|
19
|
+
export { encodeManifest, decodeManifest } from './encoding.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
isAppManifest,
|
|
23
|
+
isCapabilityManifest,
|
|
24
|
+
isDAppManifest,
|
|
25
|
+
isEdgeServiceManifest,
|
|
26
|
+
} from './guards.js';
|
package/src/sign.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signManifest — signs a manifest with a WOTS key.
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* 1. Canonicalise the manifest as deterministic JSON (sorted keys).
|
|
6
|
+
* 2. SHA3-256 the UTF-8 bytes → 32-byte digest.
|
|
7
|
+
* 3. Sign with wotsSign(seed, keyIndex, digest).
|
|
8
|
+
* 4. Derive address + PKdigest via wotsKeypairFromSeed + wotsAddressFromKeypair.
|
|
9
|
+
* 5. Return SignedManifest<T>.
|
|
10
|
+
*
|
|
11
|
+
* signerPublicKey in SignedManifest is the 32-byte WOTS PKdigest (hex, 64 chars).
|
|
12
|
+
* This is the exact value expected by verifyManifest (via wotsVerifyDigest).
|
|
13
|
+
*
|
|
14
|
+
* The caller is responsible for reserving the WOTS key index before calling
|
|
15
|
+
* this function. This package does NOT depend on @totemsdk/wots-lease.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { sha3_256 } from '@noble/hashes/sha3.js';
|
|
19
|
+
import {
|
|
20
|
+
wotsSign,
|
|
21
|
+
wotsKeypairFromSeed,
|
|
22
|
+
wotsAddressFromKeypair,
|
|
23
|
+
bytesToHex,
|
|
24
|
+
} from '@totemsdk/core';
|
|
25
|
+
import type { Manifest, SignedManifest } from './types.js';
|
|
26
|
+
|
|
27
|
+
/** Produce a deterministic canonical JSON string with sorted keys (recursive). */
|
|
28
|
+
function canonicalJson(value: unknown): string {
|
|
29
|
+
if (value === null || typeof value !== 'object') {
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
34
|
+
}
|
|
35
|
+
const obj = value as Record<string, unknown>;
|
|
36
|
+
const keys = Object.keys(obj).sort();
|
|
37
|
+
const pairs = keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`);
|
|
38
|
+
return '{' + pairs.join(',') + '}';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function manifestDigest(manifest: Manifest): Uint8Array {
|
|
42
|
+
const canonical = canonicalJson(manifest);
|
|
43
|
+
return sha3_256(new TextEncoder().encode(canonical));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function signManifest<T extends Manifest>(
|
|
47
|
+
manifest: T,
|
|
48
|
+
seed: Uint8Array,
|
|
49
|
+
keyIndex: number,
|
|
50
|
+
): Promise<SignedManifest<T>> {
|
|
51
|
+
const digest = manifestDigest(manifest);
|
|
52
|
+
const sigBytes = wotsSign(seed, keyIndex, digest);
|
|
53
|
+
const kp = wotsKeypairFromSeed(seed, keyIndex);
|
|
54
|
+
const address = wotsAddressFromKeypair(kp);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
manifest,
|
|
58
|
+
authorAddress: address,
|
|
59
|
+
signerPublicKey: bytesToHex(kp.pk),
|
|
60
|
+
signedAt: Date.now(),
|
|
61
|
+
signature: bytesToHex(sigBytes),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @totemsdk/manifest — Manifest type definitions
|
|
3
|
+
*
|
|
4
|
+
* Four manifest categories for the MVP:
|
|
5
|
+
* AppManifest — human-facing Pear app
|
|
6
|
+
* CapabilityManifest — ephemeral AI/agent service
|
|
7
|
+
* DAppManifest — KISSVM contract / covenant
|
|
8
|
+
* EdgeServiceManifest — any persistent Totem Edge service
|
|
9
|
+
*
|
|
10
|
+
* No network, no blockchain, no Hyperswarm — pure schema.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type AppPermission =
|
|
14
|
+
| 'wallet:read-balance'
|
|
15
|
+
| 'wallet:request-payment'
|
|
16
|
+
| 'omnia:open-channel'
|
|
17
|
+
| 'omnia:update-channel'
|
|
18
|
+
| 'lookup:watch-address'
|
|
19
|
+
| 'kissvm:evaluate'
|
|
20
|
+
| 'qvac:call-agent';
|
|
21
|
+
|
|
22
|
+
export interface AppManifest {
|
|
23
|
+
type: 'app';
|
|
24
|
+
appId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
authorAddress: string;
|
|
28
|
+
pearTopicKey: string;
|
|
29
|
+
price: string;
|
|
30
|
+
priceToken?: string;
|
|
31
|
+
subscriptionInterval?: number;
|
|
32
|
+
category: string[];
|
|
33
|
+
permissions: AppPermission[];
|
|
34
|
+
iconCid?: string;
|
|
35
|
+
description: string;
|
|
36
|
+
repoUrl?: string;
|
|
37
|
+
minTotemVersion: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CapabilityManifest {
|
|
41
|
+
type: 'capability';
|
|
42
|
+
capabilityId: string;
|
|
43
|
+
capabilityName: string;
|
|
44
|
+
agentAddress: string;
|
|
45
|
+
agentIdentityKey: string;
|
|
46
|
+
description: string;
|
|
47
|
+
inputSchema: object;
|
|
48
|
+
outputSchema: object;
|
|
49
|
+
pricePerCall: string;
|
|
50
|
+
priceToken?: string;
|
|
51
|
+
paymentChannel?: 'omnia' | 'onchain';
|
|
52
|
+
maxLatencyMs?: number;
|
|
53
|
+
maxCallsPerMinute?: number;
|
|
54
|
+
expiresAt: number;
|
|
55
|
+
tags: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DAppAbiEntry {
|
|
59
|
+
name: string;
|
|
60
|
+
description: string;
|
|
61
|
+
params: { name: string; type: string; description?: string }[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DAppManifest {
|
|
65
|
+
type: 'dapp';
|
|
66
|
+
dappId: string;
|
|
67
|
+
name: string;
|
|
68
|
+
version: string;
|
|
69
|
+
authorAddress: string;
|
|
70
|
+
contractHash: string;
|
|
71
|
+
contractSource?: string;
|
|
72
|
+
abi: DAppAbiEntry[];
|
|
73
|
+
price: string;
|
|
74
|
+
priceToken?: string;
|
|
75
|
+
category: string[];
|
|
76
|
+
description: string;
|
|
77
|
+
auditReport?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type EdgeServiceType =
|
|
81
|
+
| 'sensor'
|
|
82
|
+
| 'robot'
|
|
83
|
+
| 'mqtt-feed'
|
|
84
|
+
| 'proof-index'
|
|
85
|
+
| 'lookup-provider'
|
|
86
|
+
| 'omnia-router'
|
|
87
|
+
| 'calibration-authority'
|
|
88
|
+
| 'verifier'
|
|
89
|
+
| 'machine-service'
|
|
90
|
+
| 'other';
|
|
91
|
+
|
|
92
|
+
export interface EdgeServiceManifest {
|
|
93
|
+
type: 'edge-service';
|
|
94
|
+
serviceId: string;
|
|
95
|
+
name: string;
|
|
96
|
+
version: string;
|
|
97
|
+
operatorAddress: string;
|
|
98
|
+
serviceType: EdgeServiceType;
|
|
99
|
+
description: string;
|
|
100
|
+
endpoints?: Array<{
|
|
101
|
+
type: 'https' | 'mqtt' | 'hyperswarm' | 'websocket' | 'other';
|
|
102
|
+
uri: string;
|
|
103
|
+
}>;
|
|
104
|
+
capabilities: string[];
|
|
105
|
+
price?: string;
|
|
106
|
+
priceToken?: string;
|
|
107
|
+
paymentMethods?: Array<'omnia' | 'onchain' | 'invoice' | 'free'>;
|
|
108
|
+
tags: string[];
|
|
109
|
+
expiresAt?: number;
|
|
110
|
+
minTotemVersion?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type Manifest =
|
|
114
|
+
| AppManifest
|
|
115
|
+
| CapabilityManifest
|
|
116
|
+
| DAppManifest
|
|
117
|
+
| EdgeServiceManifest;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Wraps any manifest with a WOTS signature.
|
|
121
|
+
*
|
|
122
|
+
* `signerPublicKey` — hex of the full WOTS public key (required for
|
|
123
|
+
* self-contained verification via verifyManifest).
|
|
124
|
+
* `authorAddress` — the Minima address of the signer, derived at sign time
|
|
125
|
+
* and stored for quick policy checks without re-deriving from the public key.
|
|
126
|
+
*/
|
|
127
|
+
export interface SignedManifest<T extends Manifest = Manifest> {
|
|
128
|
+
manifest: T;
|
|
129
|
+
authorAddress: string;
|
|
130
|
+
signerPublicKey: string;
|
|
131
|
+
signedAt: number;
|
|
132
|
+
signature: string;
|
|
133
|
+
rootIdentityProof?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface VerifyResult {
|
|
137
|
+
valid: boolean;
|
|
138
|
+
reason?: string;
|
|
139
|
+
signerAddress: string;
|
|
140
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyManifest — verifies a SignedManifest without external state.
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* 1. Recompute the canonical manifest digest.
|
|
6
|
+
* 2. Verify the WOTS signature against the stored 32-byte PKdigest
|
|
7
|
+
* (signerPublicKey field) using wotsVerifyDigest.
|
|
8
|
+
* 3. Confirm authorAddress matches the manifest's address field.
|
|
9
|
+
*
|
|
10
|
+
* wotsVerifyDigest is used (rather than wotsVerify) because signerPublicKey
|
|
11
|
+
* stores the 32-byte WOTS PKdigest as returned by wotsKeypairFromSeed.kp.pk.
|
|
12
|
+
* The full 1088-byte key is not stored in SignedManifest to keep it compact.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { wotsVerifyDigest, hexToBytes } from '@totemsdk/core';
|
|
16
|
+
import type { SignedManifest, VerifyResult } from './types.js';
|
|
17
|
+
import { manifestDigest } from './sign.js';
|
|
18
|
+
|
|
19
|
+
function manifestAddressField(manifest: SignedManifest['manifest']): string {
|
|
20
|
+
switch (manifest.type) {
|
|
21
|
+
case 'app': return manifest.authorAddress;
|
|
22
|
+
case 'capability': return manifest.agentAddress;
|
|
23
|
+
case 'dapp': return manifest.authorAddress;
|
|
24
|
+
case 'edge-service': return manifest.operatorAddress;
|
|
25
|
+
default: {
|
|
26
|
+
const _exhaustive: never = manifest;
|
|
27
|
+
throw new Error(`verifyManifest: unknown manifest type: ${JSON.stringify(_exhaustive)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyManifest(signed: SignedManifest): VerifyResult {
|
|
33
|
+
const { manifest, signature, signerPublicKey, authorAddress } = signed;
|
|
34
|
+
|
|
35
|
+
let sigBytes: Uint8Array;
|
|
36
|
+
let pkDigest: Uint8Array;
|
|
37
|
+
try {
|
|
38
|
+
sigBytes = hexToBytes(signature);
|
|
39
|
+
pkDigest = hexToBytes(signerPublicKey);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return { valid: false, reason: `hex decode failed: ${String(e)}`, signerAddress: authorAddress };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const digest = manifestDigest(manifest);
|
|
45
|
+
|
|
46
|
+
let sigValid: boolean;
|
|
47
|
+
try {
|
|
48
|
+
sigValid = wotsVerifyDigest(sigBytes, digest, pkDigest);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { valid: false, reason: `WOTS verify threw: ${String(e)}`, signerAddress: authorAddress };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!sigValid) {
|
|
54
|
+
return { valid: false, reason: 'WOTS signature invalid', signerAddress: authorAddress };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const expectedAddress = manifestAddressField(manifest);
|
|
58
|
+
if (authorAddress !== expectedAddress) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
reason: `authorAddress mismatch: signed by '${authorAddress}' but manifest declares '${expectedAddress}'`,
|
|
62
|
+
signerAddress: authorAddress,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { valid: true, signerAddress: authorAddress };
|
|
67
|
+
}
|