@xiboplayer/crypto 0.3.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/README.md +47 -0
- package/package.json +32 -0
- package/src/index.js +1 -0
- package/src/rsa.js +75 -0
- package/src/rsa.test.js +122 -0
- package/vitest.config.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @xiboplayer/crypto
|
|
2
|
+
|
|
3
|
+
RSA key management for Xibo Player XMR registration.
|
|
4
|
+
|
|
5
|
+
Generates RSA-1024 key pairs via the Web Crypto API for display registration with Xibo CMS. The public key is sent during `RegisterDisplay` so the CMS can associate it with the display record.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### `generateRsaKeyPair()`
|
|
10
|
+
|
|
11
|
+
Generate an RSA key pair (1024-bit, RSA-OAEP/SHA-256).
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { generateRsaKeyPair } from '@xiboplayer/crypto';
|
|
15
|
+
|
|
16
|
+
const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair();
|
|
17
|
+
// publicKeyPem: -----BEGIN PUBLIC KEY-----\nMIGf...
|
|
18
|
+
// privateKeyPem: -----BEGIN PRIVATE KEY-----\nMIIC...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Returns SPKI PEM (public) and PKCS8 PEM (private) strings compatible with PHP's `openssl_get_publickey()`.
|
|
22
|
+
|
|
23
|
+
### `isValidPemKey(pem)`
|
|
24
|
+
|
|
25
|
+
Validate that a string has correct PEM structure.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { isValidPemKey } from '@xiboplayer/crypto';
|
|
29
|
+
|
|
30
|
+
isValidPemKey(publicKeyPem); // true
|
|
31
|
+
isValidPemKey('garbage'); // false
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Design
|
|
35
|
+
|
|
36
|
+
- **Zero runtime dependencies** -- uses only the Web Crypto API (available in browsers, Electron, and Node.js 16+)
|
|
37
|
+
- **RSA-1024** matches the upstream .NET player key format
|
|
38
|
+
- WebSocket XMR messages are plain JSON (no encryption needed) -- the key is only for CMS registration
|
|
39
|
+
- Key rotation is triggered by the CMS via the XMR `rekey` command
|
|
40
|
+
|
|
41
|
+
## Usage in the SDK
|
|
42
|
+
|
|
43
|
+
This package is used internally by `@xiboplayer/utils` (config) to generate and persist keys, and by `@xiboplayer/xmds` to send them during registration. You typically don't need to import it directly.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
AGPL-3.0-or-later
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xiboplayer/crypto",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "RSA key management for Xibo Player XMR registration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"vitest": "^2.0.0"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"xibo",
|
|
16
|
+
"digital-signage",
|
|
17
|
+
"crypto",
|
|
18
|
+
"rsa"
|
|
19
|
+
],
|
|
20
|
+
"author": "Pau Aliagas <linuxnow@gmail.com>",
|
|
21
|
+
"license": "AGPL-3.0-or-later",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/xibo-players/xiboplayer.git",
|
|
25
|
+
"directory": "packages/crypto"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"test:coverage": "vitest run --coverage"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generateRsaKeyPair, isValidPemKey } from './rsa.js';
|
package/src/rsa.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSA key pair generation via Web Crypto API.
|
|
3
|
+
*
|
|
4
|
+
* Generates RSA-1024 keys compatible with the upstream .NET player.
|
|
5
|
+
* The SPKI PEM public key works with PHP's openssl_get_publickey().
|
|
6
|
+
*
|
|
7
|
+
* No runtime dependencies — uses only the Web Crypto API available
|
|
8
|
+
* in browsers, Electron, and Node.js 16+.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert an ArrayBuffer (DER-encoded key) to PEM format.
|
|
13
|
+
* @param {ArrayBuffer} buffer - DER-encoded key data
|
|
14
|
+
* @param {string} type - Key type label ('PUBLIC KEY' or 'PRIVATE KEY')
|
|
15
|
+
* @returns {string} PEM-formatted key string
|
|
16
|
+
*/
|
|
17
|
+
export function arrayBufferToPem(buffer, type) {
|
|
18
|
+
const bytes = new Uint8Array(buffer);
|
|
19
|
+
let binary = '';
|
|
20
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
21
|
+
binary += String.fromCharCode(bytes[i]);
|
|
22
|
+
}
|
|
23
|
+
const base64 = btoa(binary);
|
|
24
|
+
|
|
25
|
+
// Split into 64-character lines per PEM spec
|
|
26
|
+
const lines = [];
|
|
27
|
+
for (let i = 0; i < base64.length; i += 64) {
|
|
28
|
+
lines.push(base64.substring(i, i + 64));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate an RSA key pair for XMR registration.
|
|
36
|
+
*
|
|
37
|
+
* Uses RSA-OAEP with SHA-256 and a 1024-bit modulus to match
|
|
38
|
+
* the upstream .NET player's key format.
|
|
39
|
+
*
|
|
40
|
+
* @returns {Promise<{publicKeyPem: string, privateKeyPem: string}>}
|
|
41
|
+
*/
|
|
42
|
+
export async function generateRsaKeyPair() {
|
|
43
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
44
|
+
{
|
|
45
|
+
name: 'RSA-OAEP',
|
|
46
|
+
modulusLength: 1024,
|
|
47
|
+
publicExponent: new Uint8Array([1, 0, 1]), // 65537
|
|
48
|
+
hash: 'SHA-256',
|
|
49
|
+
},
|
|
50
|
+
true, // extractable
|
|
51
|
+
['encrypt', 'decrypt']
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const publicKeyDer = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
55
|
+
const privateKeyDer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
publicKeyPem: arrayBufferToPem(publicKeyDer, 'PUBLIC KEY'),
|
|
59
|
+
privateKeyPem: arrayBufferToPem(privateKeyDer, 'PRIVATE KEY'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate that a string looks like a valid PEM key.
|
|
65
|
+
* Checks for proper BEGIN/END headers and base64 content.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} pem - String to validate
|
|
68
|
+
* @returns {boolean} true if the string appears to be valid PEM
|
|
69
|
+
*/
|
|
70
|
+
export function isValidPemKey(pem) {
|
|
71
|
+
if (!pem || typeof pem !== 'string') return false;
|
|
72
|
+
|
|
73
|
+
const pemRegex = /^-----BEGIN (PUBLIC KEY|PRIVATE KEY)-----\n[A-Za-z0-9+/=\n]+\n-----END \1-----$/;
|
|
74
|
+
return pemRegex.test(pem.trim());
|
|
75
|
+
}
|
package/src/rsa.test.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSA Key Generation Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Web Crypto API-based RSA key pair generation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { generateRsaKeyPair, isValidPemKey, arrayBufferToPem } from './rsa.js';
|
|
9
|
+
|
|
10
|
+
describe('generateRsaKeyPair', () => {
|
|
11
|
+
it('should return publicKeyPem and privateKeyPem', async () => {
|
|
12
|
+
const keys = await generateRsaKeyPair();
|
|
13
|
+
|
|
14
|
+
expect(keys).toHaveProperty('publicKeyPem');
|
|
15
|
+
expect(keys).toHaveProperty('privateKeyPem');
|
|
16
|
+
expect(typeof keys.publicKeyPem).toBe('string');
|
|
17
|
+
expect(typeof keys.privateKeyPem).toBe('string');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should produce valid PEM public key with correct headers', async () => {
|
|
21
|
+
const { publicKeyPem } = await generateRsaKeyPair();
|
|
22
|
+
|
|
23
|
+
expect(publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----\n/);
|
|
24
|
+
expect(publicKeyPem).toMatch(/\n-----END PUBLIC KEY-----$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should produce valid PEM private key with correct headers', async () => {
|
|
28
|
+
const { privateKeyPem } = await generateRsaKeyPair();
|
|
29
|
+
|
|
30
|
+
expect(privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----\n/);
|
|
31
|
+
expect(privateKeyPem).toMatch(/\n-----END PRIVATE KEY-----$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should contain base64-encoded content between headers', async () => {
|
|
35
|
+
const { publicKeyPem } = await generateRsaKeyPair();
|
|
36
|
+
|
|
37
|
+
// Extract content between headers
|
|
38
|
+
const lines = publicKeyPem.split('\n');
|
|
39
|
+
const contentLines = lines.slice(1, -1); // Remove BEGIN/END lines
|
|
40
|
+
|
|
41
|
+
for (const line of contentLines) {
|
|
42
|
+
expect(line).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
43
|
+
expect(line.length).toBeLessThanOrEqual(64);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should generate different keys on each call', async () => {
|
|
48
|
+
const keys1 = await generateRsaKeyPair();
|
|
49
|
+
const keys2 = await generateRsaKeyPair();
|
|
50
|
+
|
|
51
|
+
expect(keys1.publicKeyPem).not.toBe(keys2.publicKeyPem);
|
|
52
|
+
expect(keys1.privateKeyPem).not.toBe(keys2.privateKeyPem);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should produce keys that pass isValidPemKey', async () => {
|
|
56
|
+
const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair();
|
|
57
|
+
|
|
58
|
+
expect(isValidPemKey(publicKeyPem)).toBe(true);
|
|
59
|
+
expect(isValidPemKey(privateKeyPem)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('isValidPemKey', () => {
|
|
64
|
+
it('should accept valid PUBLIC KEY PEM', () => {
|
|
65
|
+
const pem = '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ==\n-----END PUBLIC KEY-----';
|
|
66
|
+
expect(isValidPemKey(pem)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should accept valid PRIVATE KEY PEM', () => {
|
|
70
|
+
const pem = '-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAw\n-----END PRIVATE KEY-----';
|
|
71
|
+
expect(isValidPemKey(pem)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should reject null', () => {
|
|
75
|
+
expect(isValidPemKey(null)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should reject undefined', () => {
|
|
79
|
+
expect(isValidPemKey(undefined)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reject empty string', () => {
|
|
83
|
+
expect(isValidPemKey('')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reject random string', () => {
|
|
87
|
+
expect(isValidPemKey('not-a-pem-key')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should reject mismatched headers', () => {
|
|
91
|
+
const pem = '-----BEGIN PUBLIC KEY-----\ndata\n-----END PRIVATE KEY-----';
|
|
92
|
+
expect(isValidPemKey(pem)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should reject PEM with no content', () => {
|
|
96
|
+
const pem = '-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----';
|
|
97
|
+
expect(isValidPemKey(pem)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('arrayBufferToPem', () => {
|
|
102
|
+
it('should wrap DER data in PEM headers', () => {
|
|
103
|
+
const buffer = new Uint8Array([0x30, 0x82, 0x01, 0x0a]).buffer;
|
|
104
|
+
const pem = arrayBufferToPem(buffer, 'PUBLIC KEY');
|
|
105
|
+
|
|
106
|
+
expect(pem).toMatch(/^-----BEGIN PUBLIC KEY-----\n/);
|
|
107
|
+
expect(pem).toMatch(/\n-----END PUBLIC KEY-----$/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should split base64 into 64-char lines', () => {
|
|
111
|
+
// Create a buffer large enough to produce multiple lines
|
|
112
|
+
const buffer = new Uint8Array(100).buffer;
|
|
113
|
+
const pem = arrayBufferToPem(buffer, 'PUBLIC KEY');
|
|
114
|
+
|
|
115
|
+
const lines = pem.split('\n');
|
|
116
|
+
// Skip first (BEGIN) and last (END) lines
|
|
117
|
+
const contentLines = lines.slice(1, -1);
|
|
118
|
+
for (const line of contentLines.slice(0, -1)) {
|
|
119
|
+
expect(line.length).toBe(64);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|