@things-factory/shell 10.1.31 → 10.1.34

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.
@@ -0,0 +1,130 @@
1
+ import { encryptTransformer, DecryptionFailed } from '../server/typeorm/encrypt-transform'
2
+
3
+ /**
4
+ * Reading an encrypted column has to tell two failures apart.
5
+ *
6
+ * The transformer used to answer `null` whenever decryption threw. That folded together a value
7
+ * written before the column was encrypted (plaintext, perfectly fine) and a value that is
8
+ * ciphertext the key will not open (something is wrong). Both read as "there is no value", so a
9
+ * deployment with the wrong `dataEncryptionKey` looked like a system holding no credentials —
10
+ * rows intact, one line on the console, no row named and no count.
11
+ *
12
+ * It also blocked turning encryption on at all: every row written before the change would have
13
+ * gone blank on the next read.
14
+ */
15
+
16
+ /** The shape `to` writes: 32 hex characters of IV, a colon, then hex of even length. */
17
+ const CIPHERTEXT = /^[0-9a-f]{32}:(?:[0-9a-f]{2})+$/
18
+
19
+ describe('writing an encrypted column', () => {
20
+ it('produces the iv:body shape', () => {
21
+ const stored = encryptTransformer.to('client-secret-value')
22
+
23
+ expect(stored).toMatch(CIPHERTEXT)
24
+ expect(stored).not.toContain('client-secret-value')
25
+ })
26
+
27
+ it('keeps nothing for an empty value', () => {
28
+ expect(encryptTransformer.to('')).toBeNull()
29
+ expect(encryptTransformer.to(null as any)).toBeNull()
30
+ })
31
+
32
+ it('writes a different string each time, so equal values do not look equal at rest', () => {
33
+ /* The IV is random per write. Without that, two rows holding the same secret would match. */
34
+ expect(encryptTransformer.to('same')).not.toEqual(encryptTransformer.to('same'))
35
+ })
36
+ })
37
+
38
+ describe('reading an encrypted column', () => {
39
+ it('round-trips what it wrote', () => {
40
+ expect(encryptTransformer.from(encryptTransformer.to('client-secret-value'))).toEqual('client-secret-value')
41
+ })
42
+
43
+ it('hands back a value written before the column was encrypted', () => {
44
+ /*
45
+ * ⚠ The case that makes turning encryption on possible at all. Every row already in the
46
+ * table is plaintext; if reading them answered null they would go blank on deploy.
47
+ */
48
+ expect(encryptTransformer.from('plain-client-secret')).toEqual('plain-client-secret')
49
+ })
50
+
51
+ it('hands back a JWT, which is what these columns actually hold', () => {
52
+ /*
53
+ * `Appliance.accessToken` and the shadow user's password hold signed JWTs. A JWT is dot
54
+ * separated and carries no colon, so it can never be mistaken for the ciphertext shape.
55
+ */
56
+ const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsInVzZXJUeXBlIjoiYXBwbGlhbmNlIn0.c2lnbmF0dXJl'
57
+
58
+ expect(encryptTransformer.from(token)).toEqual(token)
59
+ })
60
+
61
+ it('throws when the value is ciphertext and the key does not open it', () => {
62
+ /*
63
+ * ⚠ The case this file exists for. Answering null here is what made a wrong key look like an
64
+ * empty system. The row is still there; what is broken is the key.
65
+ */
66
+ const iv = 'a'.repeat(32)
67
+ const body = 'b'.repeat(64)
68
+
69
+ expect(() => encryptTransformer.from(`${iv}:${body}`)).toThrow(DecryptionFailed)
70
+ })
71
+
72
+ it('says in the message that the value still exists', () => {
73
+ /* Whoever reads the log has to know not to go re-issue credentials. */
74
+ const iv = 'a'.repeat(32)
75
+
76
+ expect(() => encryptTransformer.from(`${iv}:${'b'.repeat(64)}`)).toThrow(/still in the database/)
77
+ })
78
+
79
+ it('keeps nothing for an empty value', () => {
80
+ expect(encryptTransformer.from('')).toBeNull()
81
+ expect(encryptTransformer.from(null as any)).toBeNull()
82
+ })
83
+ })
84
+
85
+ describe('telling the two shapes apart', () => {
86
+ /*
87
+ * The split has to be exact in both directions: a plaintext value read as ciphertext throws on
88
+ * a perfectly good row, and a ciphertext value read as plaintext hands out the stored bytes.
89
+ */
90
+
91
+ it('does not take a colon alone for ciphertext', () => {
92
+ expect(encryptTransformer.from('host:5432')).toEqual('host:5432')
93
+ expect(encryptTransformer.from('Basic dXNlcjpwYXNz')).toEqual('Basic dXNlcjpwYXNz')
94
+ })
95
+
96
+ it('does not take hex of the wrong length for ciphertext', () => {
97
+ /* The IV is 16 bytes, so 32 hex characters and no other length. */
98
+ expect(encryptTransformer.from(`${'a'.repeat(31)}:${'b'.repeat(64)}`)).toEqual(
99
+ `${'a'.repeat(31)}:${'b'.repeat(64)}`
100
+ )
101
+ expect(encryptTransformer.from(`${'a'.repeat(33)}:${'b'.repeat(64)}`)).toEqual(
102
+ `${'a'.repeat(33)}:${'b'.repeat(64)}`
103
+ )
104
+ })
105
+
106
+ it('does not take an odd-length body for ciphertext', () => {
107
+ const odd = `${'a'.repeat(32)}:${'b'.repeat(63)}`
108
+
109
+ expect(encryptTransformer.from(odd)).toEqual(odd)
110
+ })
111
+
112
+ it('does not take uppercase hex for ciphertext', () => {
113
+ /* `to` writes lowercase. Something else wrote this, so it is not ours to decrypt. */
114
+ const upper = `${'A'.repeat(32)}:${'B'.repeat(64)}`
115
+
116
+ expect(encryptTransformer.from(upper)).toEqual(upper)
117
+ })
118
+
119
+ it('recognises its own output as ciphertext', () => {
120
+ /*
121
+ * The other direction of the same split, and the one that would silently hand out stored
122
+ * bytes if the pattern ever stopped matching what `to` writes.
123
+ */
124
+ const stored = encryptTransformer.to('secret')
125
+
126
+ expect(stored).toMatch(CIPHERTEXT)
127
+ expect(encryptTransformer.from(stored)).toEqual('secret')
128
+ expect(encryptTransformer.from(stored)).not.toEqual(stored)
129
+ })
130
+ })