@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 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
+ }
@@ -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
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: true
7
+ }
8
+ });