Skip to content

Commit 4162046

Browse files
Apply various fixes and refactoring.
Signed-off-by: Dmitri Zagidulin <dzagidulin@gmail.com>
1 parent ec71e99 commit 4162046

11 files changed

Lines changed: 595 additions & 171 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,33 @@
1919
- Update to latest d-i-core, verifier-core, was-client and didwebvh deps.
2020
- `did webvh rotate-keys` and `did service add|remove` now pass the
2121
already-resolved log state to `updateDID` as `priorMeta`, so each update
22-
verifies the history log once instead of twice.
22+
verifies the history log once instead of twice. The pairing between the log
23+
and its resolved metadata is now asserted (O(1)) before signing.
24+
- `did webvh rotate-keys` edits the authorized `updateKeys` set instead of
25+
rebuilding it from the single local key: a stage-only `--enable-prerotation`
26+
leaves the set unchanged, and an ordinary rotation replaces only the retired
27+
key, so co-authorized update keys are no longer silently revoked.
28+
- `rotate-keys --update-key` now authorizes every supplied key instead of
29+
silently dropping all but the first.
30+
- did:webvh updates persist crash-safely: the update-keys sidecar (including
31+
the superseded secret) is written before the log that authorizes the new
32+
key, and the signer loaders can recover from a rotation interrupted between
33+
the two writes.
34+
- Stored artifacts (DID documents, history logs, sidecars, wallet items) are
35+
written atomically (temp file + rename), so an interrupted write can no
36+
longer leave a truncated file.
37+
- Confirmation prompts no longer auto-confirm when stdin is not interactive;
38+
non-interactive runs must pass `--yes`, and a declined confirmation now
39+
exits 1.
40+
- An empty `SECRET_KEY_SEED` env var is treated as unset, and an invalid one
41+
is reported as a one-line error; seed derivation is shared across
42+
`key create`, `did create`, and `rotate-keys`.
43+
- Command errors that escape a runner are reported as one-line messages with
44+
exit 1 instead of surfacing as unhandled rejections; a corrupt update-keys
45+
sidecar reports its file path.
46+
- `did add-service` validates `--endpoint`/`--endpoint-json` arguments before
47+
resolving (and re-verifying) a did:webvh history log; `rotate-keys` likewise
48+
checks flag conflicts before resolution.
2349

2450
## 0.13.0 - 2026-07-12
2551

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,8 @@ Flags:
693693
new key (a bare `--stop-prerotation` reveal, or rotating to an external
694694
`--update-key`).
695695
- `-y`, `--yes` -- skip the confirmation prompt (rotation is hard to undo).
696+
Required when stdin is not interactive (scripts, cron), where the prompt
697+
cannot be asked.
696698

697699
#### Add or remove a service entry
698700

src/commands/did.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
44
import { join } from 'node:path'
55
import { tmpdir } from 'node:os'
66
import { resolveDIDFromLog } from '@interop/did-method-webvh'
7+
import { Ed25519VerificationKey } from '@interop/ed25519-verification-key'
78
import { makeDidCommand, parseDidLog } from './did.js'
89
import { makeKeyCommand } from './key.js'
910

@@ -984,6 +985,183 @@ describe('di did', () => {
984985
}
985986
})
986987

988+
it('--update-key authorizes every supplied key', async () => {
989+
const { didsDir, did, updateKeysPath } = await createSavedWebvh([
990+
'--no-prerotation'
991+
])
992+
try {
993+
const before = await readJson<{
994+
active: { publicKeyMultibase: string }
995+
}>(updateKeysPath)
996+
const activePub = before.active.publicKeyMultibase
997+
const external = await Ed25519VerificationKey.generate()
998+
const { publicKeyMultibase: externalPub } = (await external.export({
999+
publicKey: true
1000+
})) as { publicKeyMultibase: string }
1001+
1002+
await makeDidCommand().parseAsync(
1003+
[
1004+
'webvh',
1005+
'rotate-keys',
1006+
did,
1007+
'--update-key',
1008+
activePub,
1009+
externalPub,
1010+
'--keep-old-key',
1011+
'--yes'
1012+
],
1013+
{ from: 'user' }
1014+
)
1015+
1016+
const meta = await resolveStoredWebvhMeta(didsDir, did)
1017+
// Both supplied keys are authorized -- no silent drop of the extras.
1018+
assert.deepEqual(meta.updateKeys, [activePub, externalPub])
1019+
} finally {
1020+
await rm(didsDir, { recursive: true })
1021+
}
1022+
})
1023+
1024+
it('stage-only --enable-prerotation leaves a multi-key authorized set unchanged', async () => {
1025+
const { didsDir, did, updateKeysPath } = await createSavedWebvh([
1026+
'--no-prerotation'
1027+
])
1028+
try {
1029+
const before = await readJson<{
1030+
active: { publicKeyMultibase: string }
1031+
}>(updateKeysPath)
1032+
const activePub = before.active.publicKeyMultibase
1033+
const external = await Ed25519VerificationKey.generate()
1034+
const { publicKeyMultibase: externalPub } = (await external.export({
1035+
publicKey: true
1036+
})) as { publicKeyMultibase: string }
1037+
// Authorize a second (externally-held) key alongside the local one,
1038+
// keeping the local secret in `retired` so it can still sign.
1039+
await makeDidCommand().parseAsync(
1040+
[
1041+
'webvh',
1042+
'rotate-keys',
1043+
did,
1044+
'--update-key',
1045+
activePub,
1046+
externalPub,
1047+
'--keep-old-key',
1048+
'--yes'
1049+
],
1050+
{ from: 'user' }
1051+
)
1052+
logs.length = 0
1053+
1054+
await makeDidCommand().parseAsync(
1055+
['webvh', 'rotate-keys', did, '--enable-prerotation', '--yes'],
1056+
{ from: 'user' }
1057+
)
1058+
1059+
const meta = await resolveStoredWebvhMeta(didsDir, did)
1060+
// Stage only: pre-rotation is armed without revoking the co-authorized
1061+
// key.
1062+
assert.equal(meta.prerotation, true)
1063+
assert.deepEqual(meta.updateKeys, [activePub, externalPub])
1064+
} finally {
1065+
await rm(didsDir, { recursive: true })
1066+
}
1067+
})
1068+
1069+
it('refuses without --yes when stdin is not interactive', async () => {
1070+
const { didsDir, did } = await createSavedWebvh()
1071+
try {
1072+
await makeDidCommand().parseAsync(['webvh', 'rotate-keys', did], {
1073+
from: 'user'
1074+
})
1075+
assert.equal(exitCode, 1)
1076+
assert.ok(errors.some(line => line.includes('pass --yes')))
1077+
// Nothing was appended.
1078+
const meta = await resolveStoredWebvhMeta(didsDir, did)
1079+
assert.equal(meta.versionId.split('-')[0], '1')
1080+
} finally {
1081+
await rm(didsDir, { recursive: true })
1082+
}
1083+
})
1084+
1085+
it('reports a corrupt update-keys sidecar instead of crashing', async () => {
1086+
const { didsDir, did, updateKeysPath } = await createSavedWebvh()
1087+
try {
1088+
// Simulate a truncated (interrupted) sidecar write.
1089+
await writeFile(updateKeysPath, '{ "active": { "publicKeyM', 'utf8')
1090+
await makeDidCommand().parseAsync(
1091+
['webvh', 'rotate-keys', did, '--yes'],
1092+
{ from: 'user' }
1093+
)
1094+
assert.equal(exitCode, 1)
1095+
assert.ok(
1096+
errors.some(line =>
1097+
line.includes('Could not parse the update-keys sidecar')
1098+
)
1099+
)
1100+
} finally {
1101+
await rm(didsDir, { recursive: true })
1102+
}
1103+
})
1104+
1105+
it('recovers when an interrupted rotation left the sidecar ahead of the log', async () => {
1106+
const { didsDir, did } = await createSavedWebvh()
1107+
try {
1108+
const logPath = join(didsDir, 'webvh', `${did}.jsonl`)
1109+
const beforeLog = await readFile(logPath, 'utf8')
1110+
await makeDidCommand().parseAsync(
1111+
['webvh', 'rotate-keys', did, '--yes'],
1112+
{ from: 'user' }
1113+
)
1114+
// Simulate the interruption: the sidecar advanced, the log write was
1115+
// lost.
1116+
await writeFile(logPath, beforeLog, 'utf8')
1117+
logs.length = 0
1118+
1119+
await makeDidCommand().parseAsync(
1120+
['webvh', 'rotate-keys', did, '--yes'],
1121+
{ from: 'user' }
1122+
)
1123+
1124+
assert.equal(exitCode, undefined)
1125+
const meta = await resolveStoredWebvhMeta(didsDir, did)
1126+
assert.equal(meta.versionId.split('-')[0], '2')
1127+
assert.equal(meta.prerotation, true)
1128+
} finally {
1129+
await rm(didsDir, { recursive: true })
1130+
}
1131+
})
1132+
1133+
it('--with-seed treats an empty SECRET_KEY_SEED as unset', async () => {
1134+
const { didsDir, did } = await createSavedWebvh()
1135+
try {
1136+
process.env.SECRET_KEY_SEED = ''
1137+
await makeDidCommand().parseAsync(
1138+
['webvh', 'rotate-keys', did, '--with-seed', '--yes'],
1139+
{ from: 'user' }
1140+
)
1141+
assert.equal(exitCode, undefined)
1142+
const parsed = JSON.parse(logs.join('\n'))
1143+
// A seed was generated rather than the empty env value being used.
1144+
assert.match(parsed.secretKeySeed, /^z/)
1145+
} finally {
1146+
await rm(didsDir, { recursive: true })
1147+
}
1148+
})
1149+
1150+
it('reports an invalid SECRET_KEY_SEED instead of crashing', async () => {
1151+
const { didsDir, did } = await createSavedWebvh()
1152+
try {
1153+
process.env.SECRET_KEY_SEED = 'not-a-multibase-seed'
1154+
await makeDidCommand().parseAsync(
1155+
['webvh', 'rotate-keys', did, '--with-seed', '--yes'],
1156+
{ from: 'user' }
1157+
)
1158+
assert.equal(exitCode, 1)
1159+
assert.ok(errors.some(line => line.includes('Invalid SECRET_KEY_SEED')))
1160+
} finally {
1161+
await rm(didsDir, { recursive: true })
1162+
}
1163+
})
1164+
9871165
it('--with-seed emits the staged next key seed', async () => {
9881166
const { didsDir, did } = await createSavedWebvh()
9891167
try {

src/commands/did.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from './did/create.js'
1818
import {
1919
addServiceEntry,
20+
buildServiceEndpoint,
2021
buildServiceEntry,
2122
dispatchServiceUpdate,
2223
normalizeServiceId,
@@ -194,6 +195,14 @@ export function makeDidCommand(): Command {
194195
yes?: boolean
195196
}
196197
) => {
198+
try {
199+
// Argument-only validation, before the did:webvh path pays for a
200+
// full (signature-verifying) log resolution.
201+
buildServiceEndpoint(options)
202+
} catch (err) {
203+
console.error((err as Error).message)
204+
return runAndExit(Promise.resolve(1))
205+
}
197206
const transform = (
198207
current: ServiceEndpoint[],
199208
resolvedDid: string

src/commands/did/create.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
/**
22
* DID creation and key addition: `runCreate` (key, web, webvh) and `runAddKey`
3-
* (add a verification key to a stored did:web). Both share the seed-derivation,
4-
* ECDSA-curve, non-deterministic-seed-guard, and output helpers at the top of
5-
* this module; the webvh signer/sidecar plumbing is reused from
6-
* `./webvh-update.js`.
3+
* (add a verification key to a stored did:web). Both share the ECDSA-curve,
4+
* non-deterministic-seed-guard, and output helpers at the top of this module;
5+
* seed derivation comes from `../../keys/seed.js` and the webvh signer/sidecar
6+
* plumbing is reused from `./webvh-update.js`.
77
*/
8-
import {
9-
decodeSecretKeySeed,
10-
generateSecretKeySeed
11-
} from '@digitalcredentials/bnid'
128
import { driver } from '@interop/did-method-key'
139
import * as didWeb from '@interop/did-web-resolver'
1410
import { createDID } from '@interop/did-method-webvh'
@@ -29,6 +25,7 @@ import {
2925
SUPPORTED_ECDSA_CURVES,
3026
warnIfNotVcIssuanceCapable
3127
} from '../../keys/ecdsa.js'
28+
import { deriveSeed } from '../../keys/seed.js'
3229
import { exportUpdateKey, generateStagedKey } from '../../keys/webvh-update.js'
3330
import { requireSaveForMetaFlags } from '../collection-command.js'
3431
import {
@@ -96,31 +93,6 @@ async function saveDidArtifacts({
9693
console.error(`DID saved to ${docPath}`)
9794
}
9895

99-
/**
100-
* Resolve the secret key seed for a deterministic (Ed25519) key. With
101-
* `--with-seed`, an existing `SECRET_KEY_SEED` env var is honored or a fresh
102-
* seed generated; without it, only an explicitly set env seed is used. Returns
103-
* the encoded seed (echoed back to the user) and its decoded bytes (for key
104-
* generation).
105-
*
106-
* @param options {object}
107-
* @param [options.withSeed] {boolean}
108-
* @returns {Promise<{ secretKeySeed?: string, seedBytes?: Uint8Array }>}
109-
*/
110-
async function deriveSeed({ withSeed }: { withSeed?: boolean }): Promise<{
111-
secretKeySeed?: string
112-
seedBytes?: Uint8Array
113-
}> {
114-
const envSeed = process.env.SECRET_KEY_SEED
115-
const secretKeySeed = withSeed
116-
? (envSeed ?? (await generateSecretKeySeed()))
117-
: envSeed
118-
const seedBytes = secretKeySeed
119-
? decodeSecretKeySeed({ secretKeySeed })
120-
: undefined
121-
return { secretKeySeed, seedBytes }
122-
}
123-
12496
/**
12597
* Guard a non-deterministic key type against `--with-seed`: ECDSA and X25519
12698
* keys are generated non-deterministically and cannot be derived from a seed.

0 commit comments

Comments
 (0)